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
TypeScript
/**
* 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;