UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

241 lines • 11.7 kB
/** * recordRun — save a run so a viewer can show it later. * * A RECORDING is exactly three things, and each one lights a different * surface: * * `events` the typed agentfootprint stream, in the order it fired — * the story, the commentary, the summary. * `snapshot` the footprintjs run snapshot — shared state, the commit * log, and every attached recorder's data (narrative, * metrics, boundaries). * `structure` the agent's build-time chart — THE CHART. Nothing else * can draw it, and no snapshot carries it. * * Miss one and one surface goes dark. That is why this exists: every * integration that assembled the bundle by hand assembled a different, * incomplete one, and the piece most often missing was `structure` — * because a completed run does not leave it behind. This is also the * producer for the shape lens's `observeRecording()` consumes, which * until now nothing produced. * * import { recordRun } from 'agentfootprint/observe'; * * const recorder = recordRun(agent); * await agent.run({ message }); * fs.writeFileSync('run.json', JSON.stringify(recorder.toRecording())); * * …and in the viewer: * * const { recorder, runner } = observeRecording(JSON.parse(json)); // lens * <Lens recorder={recorder} runner={runner} /> * * WHAT IT WIRES THAT HAND-ROLLING GETS WRONG * ────────────────────────────────────────── * The boundary recorder — the one that puts stops on the step strip — * needs three connections, and each missing one fails differently: * * attach no boundaries at all (loud: the strip is empty) * subscribe boundaries with nothing in them (quiet: no LLM or * tool detail behind any stop) * getCommitCount every boundary stamped at commit 0 (SILENT: the * events look complete, and the strip cannot be * rebuilt offline — the commit log records what * stages wrote, never when a boundary was crossed, * so nothing downstream can repair it) * * Wire it before the run or not at all — none of the three can be * reconstructed from a finished run. * * WHAT IT DOES NOT DO * ─────────────────── * It attaches no OTHER recorders by default. `narrative()` and `metrics()` are the * consumer's choice — their data rides the snapshot when attached, and * each viewer says so when it isn't there (a Gantt with no timings shows * execution ORDER and says so; a story panel falls back to "X executed. * Wrote: y"). Attaching them behind the consumer's back would make every * recording pay for panels most runs never open. * The explicit `trustBoundaries` option adds typed metadata capture, not an * engine attachment. Its bundle is overlaid only when exporting a recording. */ import { DEFAULT_MAX_EVENTS, eventTail } from '../../events/eventTail.js'; import { boundaryRecorder } from './BoundaryRecorder.js'; import { summarizeEmbeddings } from './embeddingSummary.js'; import { trustBoundaryRecorder, } from '../../trust-boundaries/index.js'; // FOLD · the one owner of the finished run's record — shared state, commit log and every attached recorder's data // consumers read this and never re-derive it: localObservability.ts's `includeSnapshot` branch and src/artifacts/recordingArtifact.ts, which stores the JSON TEXT // detached: NO for the snapshot half — parts of it are the runner's own objects by reference: the argument // is on this function's `snapshot` field, and the handout is that same field. // Serialize to detach; that is why the artifact stores text and why includeSnapshot is opt-in. /** * Start recording a runner. Call BEFORE `run()` — a recording is * collected as the run happens and cannot be reconstructed after it. * * @param runner the Agent / composition / pattern to record. * @param options event cap and boundary detail level. * * @example * ```ts * const recorder = recordRun(agent); * await agent.run({ message: 'Weather in San Francisco?' }); * const recording = recorder.toRecording(); * recorder.stop(); * * fs.writeFileSync('run.json', JSON.stringify(recording)); * ``` */ export function recordRun(runner, options = {}) { // Only this opt-in door constructs the optional observer. The Agent's // internal recording coordinator can stay on the default browser graph // without making every consumer carry the trust capture implementation. // Validate capture options before acquiring any subscriptions. const trust = options.trustBoundaries ? trustBoundaryRecorder(options.trustBoundaries === true ? {} : options.trustBoundaries) : undefined; return recordRunWhere(runner, keepEveryEvent, options, trust); } const keepEveryEvent = () => true; /** Dispose all acquired resources; an earlier operation's error stays primary. */ function disposeSubscriptions(subscriptions, failure) { let firstFailure = failure; for (const unsubscribe of subscriptions) { try { unsubscribe(); } catch (error) { firstFailure ??= { error }; } } if (firstFailure) throw firstFailure.error; } /** * {@link recordRun} with a timeline that keeps only the events `keep` admits — * the Agent's own per-run recording (`artifacts: { recordings: true }`) passes * its run-membership rule (`bridge/eventMeta.ts · eventBelongsToRun`), so a * fact produced for ANOTHER session while this run is in flight on a shared * instance never enters it. The boundary log and the snapshot are untouched: * neither ever carried such a fact. * * @internal — the Agent's; not on any door. `recordRun` is the public producer * and records everything, as it always has. Optional typed capture is supplied * by that public factory; this coordinator never constructs another observer. */ export function recordRunWhere(runner, keep, options = {}, trust) { const subscriptions = []; try { // Fan out admitted events from the existing listener. Evaluate `keep` only // once: a stateful membership callback must not admit the timeline but // reject its corresponding fact on a second invocation. let onTrustEvent; const offTrust = trust?.subscribe({ on: (_type, listener) => { onTrustEvent = listener; return () => { onTrustEvent = undefined; }; }, }); if (offTrust) subscriptions.push(offTrust); // 1. THE TIMELINE. Subscribed before the run so nothing is missed — // the dispatcher drops events with no listener rather than queuing // them, so a late subscription starts mid-story. The bounded tail // (cap + drop count) is the shared `eventTail` helper, so a // recording and the self-explaining agent's evidence keep the same // amount and report a shortfall the same way. const tail = eventTail(options.maxEvents ?? DEFAULT_MAX_EVENTS); const offEvents = runner.on('*', (event) => { if (keep(event)) { tail.push(event); onTrustEvent?.(event); } }); subscriptions.push(offEvents); // 2. THE BOUNDARIES — all three connections, which is the whole reason // to call this instead of wiring it yourself. `getCommitCount` reads // through the runner on every boundary, so it reports the count at // that moment rather than a number captured now (when it is 0). const keepEmbeddings = options.recordEmbeddings ?? false; const boundary = boundaryRecorder({ getCommitCount: () => runner.getCommitCount(), ...(options.boundaryDetail === 'lean' ? { snapshot: 'lean' } : {}), ...(keepEmbeddings ? { recordEmbeddings: true } : {}), }); const offAttach = runner.attach(boundary); subscriptions.push(offAttach); const offTyped = boundary.subscribe(runner); subscriptions.push(offTyped); let stopped = false; let stoppedSnapshot; const snapshot = () => { const base = stopped ? stoppedSnapshot : runner.getLastSnapshot(); const retained = (keepEmbeddings ? base : summarizeEmbeddings(base)); if (!trust || retained === undefined) return retained; // One export owner, one row per capture. Never alter the engine snapshot // or replace another recorder's row just because it has the same name. const recorders = retained.recorders ?? []; if (recorders.some((row) => row.id === trust.id)) { throw new Error(`Trust boundary recorder id '${trust.id}' conflicts with an existing recorder`); } return { ...retained, recorders: [...recorders, { id: trust.id, ...trust.toSnapshot() }], }; }; return { toRecording: () => ({ // Read at freeze time. Before the run there is no snapshot; during // one it grows; after it, it is the finished run's. Unless // `recordEmbeddings` asked for them, raw vectors are summarised to // `{ dims, norm }` here — the snapshot's subflow results carry every // retrieved entry's full vector otherwise, which is what made a // single retrieval turn's recording weigh 2.76 MB in the field. The // summarisation is copy-on-write: parts of the snapshot without // embeddings are the runner's own objects, held by reference, as // before — serialize to detach. snapshot: snapshot(), events: keepEmbeddings ? tail.snapshot().events : summarizeEmbeddings(tail.snapshot().events), // The one piece a run does not leave behind — it lives on the // chart, which is built once and never changes. structure: runner.getSpec().buildTimeStructure, }), boundary, ...(trust ? { trustBoundaries: trust } : {}), get eventCount() { return tail.count; }, get droppedEvents() { return tail.dropped; }, get firstRetainedEventIndex() { return tail.firstRetainedIndex; }, stop: () => { // Idempotent: a consumer that stops in both a finally block and an // unmount handler should not detach someone else's later recorder. if (stopped) return; stopped = true; // The timeline and the boundary log end here, so the snapshot does // too: a reused runner's later log is never paired with these events. let failure; try { stoppedSnapshot = runner.getLastSnapshot(); } catch (error) { failure = { error }; } disposeSubscriptions(subscriptions, failure); }, }; } catch (error) { disposeSubscriptions(subscriptions, { error }); throw error; } } //# sourceMappingURL=recordRun.js.map