agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
526 lines • 24.4 kB
JavaScript
;
/**
* Wire each grouped strategy to its data source on the dispatcher /
* recorder substrate. These are the 4 `enable.*` facades' actual
* implementations; `RunnerBase.enable` calls them with the right
* dispatcher / attach handle.
*
* Pattern: every facade follows the same shape:
*
* 1. Resolve strategy (consumer-supplied OR default)
* 2. Run `strategy.validate?()` — early-fail on misconfig (New Relic
* panel review)
* 3. Set up subscription / projection
* 4. Apply per-strategy event-type filter (`relevantEventTypes`)
* 5. Apply per-call sample rate
* 6. Wrap calls in try/catch — route errors to `_onError` (passive
* recorder rule: never throw to caller)
* 7. Return Unsubscribe (or handle for lens)
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.attachLiveStatusStrategy = exports.attachCostStrategy = exports.attachObservabilityStrategy = void 0;
const footprintjs_1 = require("footprintjs");
const wireJson_js_1 = require("../lib/wireJson.js");
const lifecycle_js_1 = require("./lifecycle.js");
const flushDeadline_js_1 = require("./flushDeadline.js");
const statusTemplates_js_1 = require("../recorders/observability/status/statusTemplates.js");
/**
* Build the handle `enable.*` returns: the `Unsubscribe` it always was, plus
* `flush()` / `stop()` / `await using` support.
*
* THE ORDER LAW lives here, in the one object that knows both halves of what
* this subscription owns:
*
* 1. drain the detach hop — events scheduled on a `detach` driver have not
* reached the strategy yet, so flushing the strategy first ships nothing
* 2. flush the strategy's own buffer
* 3. only then may `stop()` cancel timers and release clients
*
* The reverse order is not a style preference: until 8.11.1 a `stop()` before
* a `flush()` spun the event loop of the process trying to shut down. Putting
* the order inside the handle is what makes it impossible to get wrong from
* outside.
*/
function makeStrategyHandle(args) {
let released = false;
const flush = async () => {
await args.drainDetached();
try {
await args.strategy.flush?.();
}
catch {
// Passive-recorder rule: a failing exporter reports through its own
// `_onError`; a shutdown must not inherit its exception.
}
};
const handle = Object.assign(() => {
// Detach only. Releasing a subscription NEVER stops a strategy — see
// strategies/lifecycle.ts, law 1.
if (released)
return;
released = true;
args.unsubscribe();
(0, lifecycle_js_1.releaseStrategy)(args.strategy);
}, {
flush,
stop: () => {
// Releasing THIS subscription is part of stopping: "I am done with
// it". Whether the STRATEGY stops then depends on whether anyone
// else still holds it.
handle();
(0, lifecycle_js_1.stopStrategyIfUnused)(args.strategy);
},
[lifecycle_js_1.ASYNC_DISPOSE]: async () => {
await flush();
handle();
(0, lifecycle_js_1.stopStrategyIfUnused)(args.strategy);
},
});
// So a shutdown whose deadline this handle's flush misses can name the sink.
(0, flushDeadline_js_1.rememberStrategyOf)(handle, args.strategy);
// The computed `ASYNC_DISPOSE` key gives TypeScript a symbol INDEX
// signature rather than the one well-known member `AsyncDisposable` names,
// so the cast goes through `unknown`. At runtime the key IS
// `Symbol.asyncDispose` on every engine this package supports.
return handle;
}
/**
* Sentinel returned when consumer calls `enable.X()` without supplying
* a strategy or vendor. We DON'T auto-default — that would be an
* unwelcome opinion. Consumer chose to call `enable.X` but didn't hand
* us anywhere to ship; just no-op silently and return a handle so the
* call site stays composable and `handle.flush()` is never the one path
* that throws.
*/
const NOOP_HANDLE = Object.assign(() => undefined, {
flush: () => Promise.resolve(),
stop: () => undefined,
[lifecycle_js_1.ASYNC_DISPOSE]: () => Promise.resolve(),
});
/** What a field that could not be copied reads as in a detached delivery. */
const UNCLONEABLE = '[not cloneable]';
/**
* A detached copy of an event, taken when it is SCHEDULED for detached
* delivery. `structuredClone` first — the footprintjs capture tier's `'clone'`
* policy (measured: ~2 µs for a typical event, ~30 µs for a 120 KB
* `iteration_end`). When the event holds something that cannot be copied, it
* DEGRADES AT THE LEAF ({@link copyLeaves}): the walk descends into objects
* and arrays, clones every value it can, and replaces only the value that
* cannot be copied (a method, a live handle) with {@link UNCLONEABLE} — so a
* tool result with one method keeps its data on every detached sink, as it
* does on the synchronous path. The degradation is reported. It never falls
* back to the live reference.
*/
function snapshotEvent(event, onDegraded) {
// Errors first, by the ONE wire rule (`lib/wireJson.ts`): the synchronous
// path's serializing sinks render them the same way, so both paths write the
// same bytes — and a clone would otherwise keep an Error that a detached
// sink can still read `stack` or a class's fields off.
const safe = (0, wireJson_js_1.withWireErrors)(event);
try {
return structuredClone(safe);
}
catch {
const e = safe;
const seen = new WeakMap();
onDegraded(typeof e.type === 'string' ? e.type : 'unknown', event);
return {
type: e.type,
payload: copyLeaves(e.payload, seen, 0),
meta: copyLeaves(e.meta, seen, 0),
};
}
}
/** How deep the leaf walk descends before it treats a subtree as one leaf. */
const MAX_COPY_DEPTH = 64;
/**
* `value` with every part `structuredClone` can copy copied — by
* `structuredClone` itself, so the copy has the clone's semantics (enumerable
* own data) — and every part it cannot
* copy replaced by {@link UNCLONEABLE}. Plain objects and arrays are walked;
* anything else that fails (a `Map` holding a function, a class instance with
* a live handle) is one leaf. Cycles are preserved through `seen`. An `Error`
* never reaches it: `snapshotEvent` has already rendered every one by the wire
* rule (`lib/wireJson.ts · withWireErrors`), the shape the synchronous path's
* serializing sinks write.
*/
function copyLeaves(value, seen, depth) {
try {
return structuredClone(value);
}
catch {
if (value === null || typeof value !== 'object' || depth >= MAX_COPY_DEPTH)
return UNCLONEABLE;
const held = seen.get(value);
if (held !== undefined)
return held;
if (Array.isArray(value)) {
const out = [];
seen.set(value, out);
for (const item of value)
out.push(copyLeaves(item, seen, depth + 1));
return out;
}
if (value instanceof Map || value instanceof Set || value instanceof Error)
return UNCLONEABLE;
const out = {};
seen.set(value, out);
for (const [key, item] of Object.entries(value))
out[key] = copyLeaves(item, seen, depth + 1);
return out;
}
}
/** Build a one-stage flowchart that performs `args.work(event)` and
* routes any thrown error to `args.onError`. The driver schedules
* this chart per event. */
function buildDetachWrapperChart(args) {
return (0, footprintjs_1.flowChart)('agentfootprint:detach:wrapper', async (scope) => {
const event = scope.$getArgs();
try {
args.work(event);
}
catch (err) {
args.onError?.(err instanceof Error ? err : new Error(String(err)), event);
}
}, 'wrap').build();
}
let detachExecutorSingleton;
/**
* A shared `FlowChartExecutor` used purely as the bare-executor entry point
* for `detachAndForget` / `detachAndJoinLater`. No chart actually runs through
* it — we just need its detach methods. Built on first detached event; a
* consumer who never enables `detach` never constructs one.
*
* SYNCHRONOUS since 8.11.1, and that is the whole fix. This used to `await
* import('footprintjs')`, so scheduling happened in a promise continuation:
* the detach handle was registered a microtask AFTER the event was dispatched.
* `flushAllDetached()` drains "until the registry is empty" and the registry
* was still empty when it looked, so the documented shutdown recipe returned
* `{ done: 0, failed: 0, pending: 0 }` — a clean bill of health — while events
* were still in flight, and no consumer could wait for them because the
* pending work lived in a `.then()` chain this module never handed out. The
* dynamic import also bought nothing: this module already imports
* `footprintjs` statically for `flowChart`, so the package was loaded either
* way.
*/
function getDetachExecutor() {
if (detachExecutorSingleton)
return detachExecutorSingleton;
// Trivial host chart — never run, just satisfies the constructor.
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noopHostStage = async () => { };
const noopChart = (0, footprintjs_1.flowChart)('agentfootprint:detach:host', noopHostStage, 'host').build();
detachExecutorSingleton = new footprintjs_1.FlowChartExecutor(noopChart);
return detachExecutorSingleton;
}
/** Max passes `drainDetached` will make. Bounded by construction: work
* scheduled BY the drained work is picked up by the next pass, and a drain
* that will not settle must end rather than retry forever. */
const DETACH_DRAIN_PASSES = 8;
function buildEventHandler(detach, args) {
if (!detach) {
// Sync path — current behavior.
return {
deliver: (event) => {
try {
args.work(event);
}
catch (err) {
args.onError?.(err instanceof Error ? err : new Error(String(err)), event);
}
},
drainDetached: () => Promise.resolve(),
};
}
// Detached path — schedule via the driver. We need the wrapper chart
// (for the runChild side) and the executor (for the bare-executor
// entry point that returns / discards the handle).
const wrapperChart = buildDetachWrapperChart(args);
const mode = detach.mode ?? 'forget';
const onHandle = detach.onHandle;
if (mode === 'join-later' && !onHandle) {
throw new TypeError(`[enable.*] detach.mode === 'join-later' requires \`onHandle\`. ` +
`Without it, the returned DetachHandle would be unreachable. ` +
`Pass \`onHandle: (h) => myHandles.push(h)\` (and await later via ` +
`Promise.all(myHandles.map(h => h.wait()))).`);
}
// Every event scheduled but not yet delivered to the strategy. This is the
// queue `handle.flush()` drains first, and it is the reason a detached
// export can be drained AT ALL: the events are here, not in the strategy's
// buffer, so flushing the strategy alone would ship nothing.
//
// `mode: 'forget'` keeps its meaning — the consumer never sees these
// handles. We hold them ourselves, and drop each one the moment it settles,
// so the set is bounded by what is genuinely in flight. (`detachAndForget`
// in footprintjs IS `detachAndJoinLater` with the handle discarded, so this
// is the same scheduling path, not a second one.)
const inFlight = new Set();
// An uncloneable payload is reported ONCE per event type — enough for an
// operator to see the degradation, not a stream per event.
const degradedTypes = new Set();
const reportDegraded = (type, event) => {
if (degradedTypes.has(type))
return;
degradedTypes.add(type);
args.onError?.(new Error(`[enable.*] detached delivery: '${type}' carried a value structuredClone cannot copy ` +
`(a function, a symbol, a live handle). The event was delivered with only that value ` +
`replaced by '${UNCLONEABLE}' — the rest of the payload intact — never as a live reference.`), event);
};
const track = (handle) => {
inFlight.add(handle);
const forget = () => {
inFlight.delete(handle);
};
void handle.wait().then(forget, forget);
};
return {
deliver: (event) => {
// Schedules in THIS tick and returns immediately — the driver owns when
// the work runs, we only hand it over. Handing over synchronously is what
// puts the handle in the detach registry before the caller's next line,
// so a shutdown that calls `flushAllDetached()` actually drains this
// event instead of finding an empty registry (8.11.1).
//
// The driver runs the work LATER — after the tool that emitted a
// `tool_start` has executed, after the stage that produced a result has
// moved on. What it delivers must be the event AS IT WAS when it was
// scheduled, never the live object: a tool that writes a bearer header
// into its own arguments, or a result mutated after `tool_end`, would
// otherwise reach the sink as if the event had carried it. So the event
// is snapshotted HERE (`snapshotEvent`) — footprintjs's deferred-observer
// law, whose default capture is `'clone'` (`DeferredObserverTier`).
try {
const snapshot = snapshotEvent(event, reportDegraded);
const handle = getDetachExecutor().detachAndJoinLater(detach.driver, wrapperChart, snapshot);
track(handle);
// Caller validates onHandle is set when mode !== 'forget' (see
// mode-discrimination above; the mode='joinLater' branch requires it).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (mode !== 'forget')
onHandle(handle);
}
catch (err) {
args.onError?.(err instanceof Error ? err : new Error(String(err)), event);
}
},
drainDetached: async () => {
for (let pass = 0; pass < DETACH_DRAIN_PASSES && inFlight.size > 0; pass++) {
await Promise.allSettled([...inFlight].map((handle) => handle.wait()));
// Let the `forget` continuations above run, so `inFlight.size` tells
// the truth before the next pass reads it.
await Promise.resolve();
}
},
};
}
/**
* The events that mean "a run just ended", for `flushOn: 'run-end'`.
*
* Named explicitly rather than pattern-matched, because a flush is a network
* round trip and guessing which events are terminal would fire it in the
* middle of runs. An Agent ends a turn; a composition (Sequence, Parallel,
* Conditional, Loop) exits. A runner that emits neither simply never
* auto-flushes — `flushOn` promises nothing it cannot see.
*/
const RUN_END_EVENTS = [
'agentfootprint.agent.turn_end',
'agentfootprint.composition.exit',
];
/**
* Wire `flushOn: 'run-end'`. Returns the subscriptions to release when the
* handle is unsubscribed (empty for the default `'manual'`).
*
* The flush is FIRED, never awaited: `run()` resolves when the run is done,
* and nothing here may add exporter latency to it. Failures are already
* swallowed inside `handle.flush()` and reported through the strategy's own
* `_onError`.
*/
function subscribeRunEndFlush(dispatcher, flushOn, handle) {
if (flushOn !== 'run-end')
return [];
return RUN_END_EVENTS.map((type) => dispatcher.on(type, () => {
void handle.flush();
}));
}
const TIER_FILTER = {
minimal: (t) => t.startsWith('agentfootprint.error.') || t.startsWith('agentfootprint.agent.'),
standard: (t) => !t.startsWith('agentfootprint.stream.token'),
firehose: () => true,
};
function attachObservabilityStrategy(dispatcher, opts = {}) {
const strategy = opts.strategy;
// Consumer chose to call enable.observability() but didn't supply
// a strategy. Don't auto-default — that imposes an opinion. Just
// no-op so the call site stays composable.
if (!strategy)
return NOOP_HANDLE;
strategy.validate?.();
const tierFilter = TIER_FILTER[opts.tier ?? 'standard'];
const sampleRate = opts.sampleRate ?? 1;
const relevant = strategy.relevantEventTypes
? new Set(strategy.relevantEventTypes)
: null;
// Build the event handler ONCE per attach call. Sync if no
// `opts.detach`; otherwise schedules on the driver so the agent
// loop never blocks on slow exporters.
const events = buildEventHandler(opts.detach, {
work: (event) => strategy.exportEvent(event),
onError: (err, event) => strategy._onError?.(err, event),
});
(0, lifecycle_js_1.retainStrategy)(strategy);
const subscriptions = [];
const handle = makeStrategyHandle({
strategy,
unsubscribe: () => {
for (const off of subscriptions)
off();
},
drainDetached: events.drainDetached,
});
subscriptions.push(dispatcher.on('*', (event) => {
if (!tierFilter(event.type))
return;
if (relevant && !relevant.has(event.type))
return;
if (sampleRate < 1 && Math.random() > sampleRate)
return;
events.deliver(event);
}), ...subscribeRunEndFlush(dispatcher, opts.flushOn, handle));
return handle;
}
exports.attachObservabilityStrategy = attachObservabilityStrategy;
/**
* Subscribe to `agentfootprint.cost.tick` events, project payload into
* the canonical `CostTick` shape, hand to strategy.
*
* **The projection reads `CostTickPayload`, which is not spelled like
* `CostTick`.** It never was: the wire payload `emitCostTick` produces carries
* `tokensInput` / `tokensOutput` / `estimatedUsd` for the call just billed and
* a nested `cumulative` for the run so far, while the strategy-facing
* {@link CostTick} says `recent*` / `cumulative*`. This projection used to read
* the strategy names off the payload, so every field resolved to `?? 0` and
* every attached cost strategy — billing sinks, budget breakers, dashboards —
* was handed a tick of zeros. A tick of zeros is worse than no tick: it looks
* like a run that cost nothing.
*
* The `?? p.recent*` / `?? p.cumulative*` fallbacks below are NOT that bug left
* in place. They cover an event hand-constructed from the public `CostTick`
* shape rather than emitted by the library — the same allowance the X-Ray and
* OTel exporters already make for this one event type (`xray.ts`, `otel.ts`),
* and the real shape is what wins when both are present.
*/
function attachCostStrategy(dispatcher, opts = {}) {
const strategy = opts.strategy;
if (!strategy)
return NOOP_HANDLE;
strategy.validate?.();
// Cost strategy detach mirrors observability — sync by default,
// schedules on the driver when `opts.detach` is set. Useful when
// `recordCost` does heavy work (per-tick DB write, vendor budget
// API, etc.).
const events = buildEventHandler(opts.detach, {
work: (tickInput) => strategy.recordCost(tickInput),
onError: (err, tickInput) => strategy._onError?.(err, tickInput),
});
(0, lifecycle_js_1.retainStrategy)(strategy);
const subscriptions = [];
const handle = makeStrategyHandle({
strategy,
unsubscribe: () => {
for (const off of subscriptions)
off();
},
drainDetached: events.drainDetached,
});
subscriptions.push(dispatcher.on('agentfootprint.cost.tick', (event) => {
const p = event.payload;
const cum = (p.cumulative ?? {});
// `iteration` and `runtimeStageId` ride the ENVELOPE, not the payload —
// the dispatcher stamps them on every event, so a strategy that wants to
// attribute spend to a loop turn has to be handed them from there.
// `meta` is optional here only because an event can be dispatched by hand.
const meta = event.meta;
const tick = {
cumulativeInputTokens: Number(cum.tokensInput ?? p.cumulativeInputTokens ?? 0),
cumulativeOutputTokens: Number(cum.tokensOutput ?? p.cumulativeOutputTokens ?? 0),
cumulativeCostUsd: Number(cum.estimatedUsd ?? p.cumulativeCostUsd ?? 0),
recentInputTokens: Number(p.tokensInput ?? p.recentInputTokens ?? 0),
recentOutputTokens: Number(p.tokensOutput ?? p.recentOutputTokens ?? 0),
recentCostUsd: Number(p.estimatedUsd ?? p.recentCostUsd ?? 0),
model: String(p.model ?? 'unknown'),
// Optional on the payload for one honest reason (see `CostTickPayload`):
// a window strategy's summarizer spend may not name its provider. Absent
// stays absent rather than becoming 'unknown', which would be a claim.
...(typeof p.provider === 'string' ? { provider: p.provider } : {}),
...(typeof meta?.iterIndex === 'number'
? { iteration: meta.iterIndex }
: typeof p.iteration === 'number'
? { iteration: p.iteration }
: {}),
...(typeof meta?.runtimeStageId === 'string'
? { runtimeStageId: meta.runtimeStageId }
: typeof p.runtimeStageId === 'string'
? { runtimeStageId: p.runtimeStageId }
: {}),
};
events.deliver(tick);
}), ...subscribeRunEndFlush(dispatcher, opts.flushOn, handle));
return handle;
}
exports.attachCostStrategy = attachCostStrategy;
/**
* Subscribe to '*', maintain a rolling event log, project current
* thinking state on each event, render via templates, hand to strategy.
*
* Lower bound on emissions: dedupes — only fires `renderStatus` when
* the rendered line CHANGES (avoids floods on every token).
*/
/** Sliding-window cap for `attachLiveStatusStrategy`'s internal event
* log. Long-lived agent servers would otherwise leak memory through
* unbounded growth (per OTel SIG panel review). The cap is high
* enough that `selectStatus` always sees the relevant recent
* history. */
const LIVE_STATUS_LOG_CAP = 1000;
function attachLiveStatusStrategy(dispatcher, opts) {
opts.strategy.validate?.();
const templates = { ...statusTemplates_js_1.defaultStatusTemplates, ...(opts.templates ?? {}) };
const ctx = { appName: opts.appName ?? 'Agent' };
const eventLog = [];
let lastLine = null;
(0, lifecycle_js_1.retainStrategy)(opts.strategy);
const subscriptions = [];
const handle = makeStrategyHandle({
strategy: opts.strategy,
unsubscribe: () => {
for (const off of subscriptions)
off();
},
// Live status renders inline (a UI callback); there is no detach hop.
drainDetached: () => Promise.resolve(),
});
subscriptions.push(dispatcher.on('*', (event) => {
eventLog.push(event);
// Sliding-window — drop oldest when over cap. O(1) amortized
// because shift() runs only once per overflow.
while (eventLog.length > LIVE_STATUS_LOG_CAP)
eventLog.shift();
const state = (0, statusTemplates_js_1.selectStatus)(eventLog);
if (!state) {
lastLine = null;
return;
}
const line = (0, statusTemplates_js_1.renderStatusLine)(state, ctx, templates);
if (line === null || line === lastLine)
return;
lastLine = line;
try {
opts.strategy.renderStatus({ line, state });
}
catch (err) {
opts.strategy._onError?.(err instanceof Error ? err : new Error(String(err)), event);
}
}), ...subscribeRunEndFlush(dispatcher, opts.flushOn, handle));
return handle;
}
exports.attachLiveStatusStrategy = attachLiveStatusStrategy;
//# sourceMappingURL=attach.js.map