UNPKG

agentfootprint

Version:

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

211 lines (210 loc) • 10.8 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 type { Runner } from '../../core/runner.js'; import type { AgentfootprintEvent } from '../../events/registry.js'; import { type BoundaryRecorder } from './BoundaryRecorder.js'; import { type TrustBoundaryRecorder, type TrustBoundaryRecorderOptions } from '../../trust-boundaries/index.js'; /** * One frozen run — everything a viewer needs, and nothing it doesn't. * * The field names are the ones `observeRecording()` reads, so a recording * is handed over whole: no mapping, no adapter, no per-integration shape. */ export interface Recording { /** The footprintjs run snapshot. `undefined` if nothing has run yet. */ readonly snapshot: unknown; /** The typed events the run fired, in order. */ readonly events: readonly AgentfootprintEvent[]; /** The agent's build-time chart — the only route to a drawable graph. */ readonly structure: unknown; } export interface RecordRunOptions { /** * Cap on retained events. The oldest are dropped once the cap is hit, * so a long-running server records a bounded tail rather than growing * without limit. Default 10,000 — a few hundred KB for a typical turn, * and far more than any single turn fires. * * A recording that dropped events says so: `droppedEvents` counts them * rather than leaving a consumer to wonder why its timeline starts * mid-run. * * The cap counts DISTINCT events. Every model call re-announces each piece * of its context (`agentfootprint.context.injected`); an announcement equal, * field for field, to one the recording still holds is kept in its place but * holds no slot, and shares that one's payload. Counted, those repeats grow * with the square of the iteration count and evicted the start of a long run * (a 100-iteration run opened at iteration ~33). The tail stays bounded: * slots and repeats together are capped at ten times `maxEvents` (each * repeat is still a small envelope), past which the oldest go, as dropped. */ readonly maxEvents?: number; /** * How much of the boundary log rides the snapshot: * - `'full'` (default) — every field, content included. What an * offline step strip needs to show detail behind each stop. * - `'lean'` — BoundaryEvents structure only, no captured payloads. The * strip still rebuilds; the detail panels are empty, and a * consumer reading the bundle's `meta.mode` can say so. * This changes only the BoundaryEvents bundle, not the raw event tail or * snapshot state. It is not a whole-recording redaction policy. */ readonly boundaryDetail?: 'full' | 'lean'; /** * Keep raw embedding vectors in the recording (8.20.0). Default `false`: * every `embedding` / `embeddings` field — in boundary payloads, in the * snapshot's subflow results, in tool results riding the event stream — * is replaced with its `{ dims, norm }` summary when the recording is * frozen. * * Field measurement behind the default: one retrieval turn's recording * weighed 2.76 MB, ~1.1 MB of it embedding floats, because the * memory-read subflow's boundary output carries each retrieved entry's * full vector. Nothing that renders a recording reads those floats; the * retrieval evidence a debugger needs (score, passage, document, * rejected candidates) carries no vectors and is untouched. Set `true` * only when a consumer genuinely replays raw vectors offline. */ readonly recordEmbeddings?: boolean; /** * Capture a bounded, content-minimized view of selected middleware, * permission and credential events. Default off. The facts are collected * at typed dispatch, never recovered from the event tail or execution tree. * * The `TrustBoundaries` row rides `snapshot.recorders`; other recording * fields can still contain raw content. `stop()` pins the base snapshot * (as it does in every mode), so runner reuse cannot substitute another log. */ readonly trustBoundaries?: boolean | TrustBoundaryRecorderOptions; } /** A recording in progress. Keep it until the run ends, then freeze it. */ export interface RunRecorder { /** * Freeze what has happened so far into `{ snapshot, events, structure }`. * Call it after the run; calling it during one gives a partial * recording, which is legitimate for a crash report but will show a * timeline that stops mid-run. * * The events array is a fresh copy, so a recording taken and stored * does not keep growing behind the caller's back. `snapshot` and * `structure` are the runner's own objects, held by reference — * serialize to detach. */ toRecording(): Recording; /** The boundary recorder, for richer live queries (slot rows, ranges). */ readonly boundary: BoundaryRecorder; /** Opt-in typed trust capture; its detached facts remain readable after stop. */ readonly trustBoundaries?: TrustBoundaryRecorder; /** How many events have been captured — re-announced context included (see * `maxEvents`). */ readonly eventCount: number; /** Events discarded to stay under `maxEvents`. `0` on a normal turn. */ readonly droppedEvents: number; /** Original stream position of the first retained event (9.60.0) — the * captured events are stream positions * `[firstRetainedEventIndex, firstRetainedEventIndex + eventCount)`. */ readonly firstRetainedEventIndex: number; /** * Stop recording. The events already captured stay readable, so * `toRecording()` after `stop()` is the normal end of a run; call it * when the runner outlives the recording (a server that records one * turn out of many). * * Pins the current snapshot by reference — in every mode, with or without * `trustBoundaries` — because the timeline and the boundary log end here * too: a reused runner's later run is never paired with these events. It * pins even if the run is unfinished; it does not assert completion or * drain pending events. A failing snapshot read still attempts every * subscription's cleanup, and no later run's snapshot is substituted. */ stop(): void; } /** * 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 declare function recordRun(runner: Runner, options?: RecordRunOptions): RunRecorder; /** * {@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 declare function recordRunWhere(runner: Runner, keep: (event: AgentfootprintEvent) => boolean, options?: Omit<RecordRunOptions, 'trustBoundaries'>, trust?: TrustBoundaryRecorder): RunRecorder;