UNPKG

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
"use strict"; /** * 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