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
JavaScript
/**
* 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