agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
649 lines • 29 kB
JavaScript
/**
* RunStepRecorder — slider-ready ordered list of RunSteps, BUILT
* INCREMENTALLY during traversal. Real-time recorder, not a walker.
*
* Pattern: extends `SequenceStore<RunStep>` (shared storage shelf)
* and implements `CombinedRecorder` (FlowRecorder hooks).
* Subscribes to the agentfootprint typed-event dispatcher
* for actor-arrow events. Each event handler decides whether
* to emit a step; state lives on the instance and persists
* across the run.
* Role: The single source of truth for "what slider positions
* exist in this run, and what transitions does each light
* up." Lens consumers attach the recorder once and read
* `getSteps()` — no per-render re-derivation.
*
* Why this matters: walking a saved event log on every live update violates
* footprintjs's core principle ("collect during traversal, never
* post-process"), yielding O(N²) total work for a streaming run. The recorder pattern
* is O(N) — one handler call per event — and matches BoundaryRecorder /
* FlowchartRecorder / KeyedStore idioms throughout the library.
*
* Saved-event consumers feed a complete recording to `ingestDomainEvents`
* once, then read `getSteps(drillPath?)`. Live consumers attach the recorder
* directly via `runner.attach(rec)` instead of replaying on every update.
*/
import { ROOT_RUNTIME_STAGE_ID, ROOT_SUBFLOW_ID, SequenceStore } from 'footprintjs/trace';
import { splitStageId } from 'foottrace';
import { createRunIdObserver } from './observeRunId.js';
import { ForkTracker } from './internal/ForkTracker.js';
import { SequenceSiblingTracker } from './internal/SequenceSiblingTracker.js';
import { CandidateAnswerBuffer } from './internal/CandidateAnswerBuffer.js';
import { RootInferrer } from './internal/RootInferrer.js';
import { ActorArrowClassifier } from './internal/ActorArrowClassifier.js';
let _counter = 0;
/** Factory — matches the `boundaryRecorder()` / `topologyRecorder()` style. */
export function runStepRecorder(options = {}) {
return new RunStepRecorder(options);
}
// ─── Constants ──────────────────────────────────────────────────────
const ACTOR_USER = 'actor:user';
const LEAF_PRIMITIVES = new Set(['Agent', 'LLMCall']);
function isLeafPrimitive(kind) {
return kind !== undefined && LEAF_PRIMITIVES.has(kind);
}
function lastSegment(subflowId) {
const i = subflowId.lastIndexOf('/');
return i >= 0 ? subflowId.slice(i + 1) : subflowId;
}
function isPathPrefix(prefix, path) {
if (prefix.length > path.length)
return false;
for (let i = 0; i < prefix.length; i++) {
if (prefix[i] !== path[i])
return false;
}
return true;
}
function pathFromCtx(s) {
const segments = s ? s.split('/').filter(Boolean) : [];
return [ROOT_SUBFLOW_ID, ...segments];
}
// ─── Recorder class ────────────────────────────────────────────────
/**
* Real-time slider-step recorder. Emits a `RunStep` whenever an event
* marks a meaningful slider transition. State persists on the instance
* so successive events update bookkeeping in O(1).
*
* Attach via `runner.attach(rec)` for FlowRecorder events; call
* `rec.subscribe(runner.dispatcher)` for actor-arrow events. The
* `getSteps(drillPath?)` method returns the already-built list (no
* walking) with optional drill-scope filtering.
*/
export class RunStepRecorder {
id;
/** Composition: storage shelf for the slider-step sequence. */
store = new SequenceStore();
/** Run-boundary observer — fires this.clear() when traversalContext.runId
* changes between events. THIS IS THE FIX for the Parallel multi-run
* aliasing bug — without it `forkKey = ${parent}@${rid}` collides
* because rid resets to `seed#0` on each run. */
runIdGuard = createRunIdObserver(() => this.resetForNewRun());
// ── Composed sub-trackers (one concern each) ─────────────────────
/** Stack of currently-open boundaries. The recorder owns this
* directly because it's a simple stack and frames are recorder-
* shaped. */
boundaryStack = [];
/** Fork-emission coalescing + branch-exit tally. */
forks = new ForkTracker();
/** Tracks the most-recent leaf exit per depth → "forwards" handoff. */
siblings = new SequenceSiblingTracker();
/** Buffers a "this MIGHT be the answer" leaf until onRunEnd. */
answerBuffer = new CandidateAnswerBuffer();
/** Run-root inference state machine (leaf vs composition). */
rootInferrer = new RootInferrer();
/** llm.start / llm.end actor-arrow classifier. */
actorArrows = new ActorArrowClassifier();
/** Has the first `asks` step fired? */
asksEmitted = false;
constructor(options = {}) {
this.id = options.id ?? `run-step-${++_counter}`;
}
/**
* Emit a RunStep, auto-mirroring `anchor.runtimeStageId` to the
* top-level `runtimeStageId` field that the keyed index uses. Single
* source of truth (the anchor) — never inconsistent with the storage
* key.
*/
push(step) {
this.store.push({ ...step, runtimeStageId: step.anchor.runtimeStageId });
}
/** Internal seq-numbering helper — mirrors the store size so each
* RunStep gets a unique 0-based index in emit order. */
get entryCount() {
return this.store.size;
}
clear() {
this.resetForNewRun();
this.runIdGuard.reset();
}
/** Internal — wipe all per-run state WITHOUT resetting the runIdGuard
* itself. Called by `clear()` (which then resets the guard) AND by
* the runIdGuard's onNewRun callback (where the guard is mid-update
* and must NOT be reset, only the recorder's data should be).
*
* Note: each sub-tracker owns its OWN clear; the orchestrator just
* fans out. Adding new state to a sub-tracker requires no edit here. */
resetForNewRun() {
this.store.clear();
this.boundaryStack = [];
this.forks.clear();
this.siblings.clear();
this.answerBuffer.clear();
this.rootInferrer.clear();
this.actorArrows.clear();
this.asksEmitted = false;
}
observeRunId(runId) {
this.runIdGuard.observe(runId);
}
// ── FlowRecorder hooks ─────────────────────────────────────────
onRunStart(event) {
this.observeRunId(event.traversalContext?.runId);
// Nothing to emit yet — the first leaf entry / fork event starts
// the slider.
}
onRunEnd(event) {
this.observeRunId(event.traversalContext?.runId);
// Emit the deferred `answers` step for the last leaf exit at run
// scope. Without this, runs with a Sequence-rooted shape never
// see their final answer reflected on the slider.
this.flushCandidateAnswer();
}
onSubflowEntry(event) {
if (!event.subflowId)
return;
this.observeRunId(event.traversalContext?.runId);
const ctx = event.traversalContext;
const runtimeStageId = ctx?.runtimeStageId ?? '';
const subflowPath = pathFromCtx(ctx?.subflowPath);
const depth = subflowPath.length - 1;
const ts = Date.now();
const subflowId = event.subflowId;
const description = event.description;
const primitiveKind = parsePrimitiveKind(description);
// Delegate root inference to the dedicated state machine.
this.rootInferrer.observeSubflowEntry(depth, primitiveKind);
const frame = {
subflowId,
subflowPath,
...(primitiveKind ? { primitiveKind } : {}),
depth,
};
const isLeaf = isLeafPrimitive(primitiveKind);
if (this.forks.isForkChild(subflowId)) {
// Branch wrapper of a tracked fork — push frame so the matching
// exit can tally for the merge step. No sequential emission.
this.boundaryStack.push(frame);
return;
}
if (isLeaf) {
const prevSibling = this.siblings.peekPrevSibling(depth);
if (prevSibling) {
// Sequence-style sibling handoff: previous leaf exited, new
// leaf entered at the same depth → emit `forwards`.
this.push({
seq: this.entryCount,
kind: 'sequential',
transitions: [{ from: prevSibling, to: subflowId, via: 'next', label: 'forwards' }],
anchor: { runtimeStageId, subflowPath },
label: 'forwards',
tsMs: ts,
});
}
else if (!this.asksEmitted) {
// First leaf in the run — User asks it.
this.push({
seq: this.entryCount,
kind: 'sequential',
transitions: [{ from: ACTOR_USER, to: subflowId, via: 'next', label: 'asks' }],
anchor: { runtimeStageId, subflowPath },
label: 'asks',
tsMs: ts,
});
this.asksEmitted = true;
}
}
this.boundaryStack.push(frame);
}
onSubflowExit(event) {
this.observeRunId(event.traversalContext?.runId);
const ctx = event.traversalContext;
const runtimeStageId = ctx?.runtimeStageId ?? '';
const ts = Date.now();
const frame = this.boundaryStack.pop();
if (!frame)
return;
const isLeaf = isLeafPrimitive(frame.primitiveKind);
// Fork-branch exit: ForkTracker tallies completion and signals
// when ALL branches have exited so we can emit the merge step.
const merge = this.forks.recordChildExit(frame.subflowId);
if (merge) {
this.push({
seq: this.entryCount,
kind: 'merge',
transitions: merge.branches.map((sid) => ({
from: sid,
to: ACTOR_USER,
via: 'next',
label: lastSegment(sid),
})),
anchor: { runtimeStageId, subflowPath: frame.subflowPath },
label: `merge (${merge.branches.length})`,
tsMs: ts,
meta: { kind: 'merge', mergedCount: merge.branches.length },
});
return;
}
// If it WAS a fork child but not the last to exit, still skip the
// leaf-handoff path below.
if (this.forks.isForkChild(frame.subflowId))
return;
if (isLeaf) {
// Sibling handoff + answer-candidate buffering. Both delegated.
this.siblings.recordExit(frame.depth, frame.subflowId);
this.answerBuffer.set(frame, ts, runtimeStageId);
}
}
onFork(event) {
this.observeRunId(event.traversalContext?.runId);
const ctx = event.traversalContext;
const runtimeStageId = ctx?.runtimeStageId ?? '';
const subflowPath = pathFromCtx(ctx?.subflowPath);
const ts = Date.now();
const depth = subflowPath.length - 1;
// ForkTracker handles registration + dedup. Cross-run collisions
// (Committee + TolerantCommittee back-to-back, both emitting
// `seed#0` as the parent stage) are prevented by observeRunId
// above — the tracker's state is wiped when runId changes.
const reg = this.forks.registerFork(event.parent, runtimeStageId, event.children);
if (!reg.fresh)
return;
// Fork at depth 0 = Parallel root signal.
this.rootInferrer.observeFork(depth);
const branches = [...event.children];
this.push({
seq: this.entryCount,
kind: 'fork',
transitions: branches.map((childName) => ({
from: ACTOR_USER,
to: childName,
via: 'fork-branch',
label: childName,
})),
anchor: { runtimeStageId, subflowPath },
label: `fork (${branches.length})`,
tsMs: ts,
meta: { kind: 'fork', parentSubflowId: event.parent },
});
}
onDecision(event) {
this.observeRunId(event.traversalContext?.runId);
const ctx = event.traversalContext;
// Skip Agent-internal decisions — those are encoded by the actor
// arrows that follow.
const stageId = ctx?.stageId ?? '';
const { localStageId } = splitStageId(stageId);
if (isAgentInternalStageId(localStageId))
return;
const runtimeStageId = ctx?.runtimeStageId ?? '';
const subflowPath = pathFromCtx(ctx?.subflowPath);
const ts = Date.now();
const depth = subflowPath.length - 1;
this.rootInferrer.observeDecision(depth);
this.push({
seq: this.entryCount,
kind: 'decide',
transitions: [
{
from: runtimeStageId,
to: event.chosen,
via: 'decision-branch',
label: event.chosen,
},
],
anchor: { runtimeStageId, subflowPath },
label: `routes to ${event.chosen}`,
tsMs: ts,
meta: {
kind: 'decide',
chosen: event.chosen,
...(event.rationale ? { rationale: event.rationale } : {}),
},
});
}
onLoop(event) {
this.observeRunId(event.traversalContext?.runId);
const ctx = event.traversalContext;
const runtimeStageId = ctx?.runtimeStageId ?? '';
const subflowPath = pathFromCtx(ctx?.subflowPath);
const ts = Date.now();
const depth = subflowPath.length - 1;
this.rootInferrer.observeLoop(depth);
this.push({
seq: this.entryCount,
kind: 'iteration',
transitions: [
{
from: event.target,
to: event.target,
via: 'loop-iteration',
label: `iter ${event.iteration}`,
},
],
anchor: { runtimeStageId, subflowPath },
label: `iter ${event.iteration}`,
tsMs: ts,
meta: { kind: 'iteration', index: event.iteration, target: event.target },
});
}
// ── Typed-event subscription (actor arrows) ──────────────────────
/**
* Subscribe to the runner's typed-event dispatcher and emit a
* `react` RunStep on every `llm.start` / `llm.end`. The recorder
* classifies `actorArrow` locally (mirrors BoundaryRecorder's
* pattern) so consumers don't have to depend on BoundaryRecorder's
* own subscription order.
*/
subscribe(dispatcher) {
return dispatcher.on('*', (event) => this.ingestTypedEvent(event));
}
/** Internal — also called by `ingestDomainEvent` for saved-event replay.
*
* NOTE: deliberately does NOT call observeRunId(event.meta.runId).
* The agentfootprint dispatcher's runId is a DIFFERENT generator
* than footprintjs's traversalContext.runId — mixing them would
* toggle lastRunId on every event and trigger a false reset.
* Run-boundary detection happens reliably on the FlowRecorder side
* (onRunStart fires FIRST in any new run before any typed event). */
ingestTypedEvent(event) {
if (event.type === 'agentfootprint.stream.llm_start') {
const meta = event.meta;
const runtimeStageId = meta.runtimeStageId ?? '';
const subflowPath = [ROOT_SUBFLOW_ID, ...(meta.subflowPath ?? [])];
const ts = meta.wallClockMs;
const arrow = this.actorArrows.classifyStart();
const llmStage = `stage:llm:${runtimeStageId}`;
const from = arrow === 'tool→llm' ? `stage:tool:${runtimeStageId}` : ACTOR_USER;
this.push({
seq: this.entryCount,
kind: 'react',
transitions: [{ from, to: llmStage, via: 'actor-arrow', label: arrow }],
anchor: { runtimeStageId, subflowPath },
label: arrow,
tsMs: ts,
meta: { kind: 'react', actorArrow: arrow },
});
}
else if (event.type === 'agentfootprint.stream.llm_end') {
const p = event.payload;
const meta = event.meta;
const runtimeStageId = meta.runtimeStageId ?? '';
const subflowPath = [ROOT_SUBFLOW_ID, ...(meta.subflowPath ?? [])];
const ts = meta.wallClockMs;
const arrow = this.actorArrows.classifyEnd(p.toolCallCount);
const llmStage = `stage:llm:${runtimeStageId}`;
const to = arrow === 'llm→user' ? ACTOR_USER : `stage:tool:${runtimeStageId}`;
this.push({
seq: this.entryCount,
kind: 'react',
transitions: [{ from: llmStage, to, via: 'actor-arrow', label: arrow }],
anchor: { runtimeStageId, subflowPath },
label: arrow,
tsMs: ts,
meta: { kind: 'react', actorArrow: arrow },
});
}
}
// ── Replay API (for saved recordings / tests / offline analysis) ───
/**
* Replay a complete saved `BoundaryRecorder` event list in recorded order.
* Fork branches sharing a parent and timestamp are ingested together, so
* one parallel fork keeps all its transitions and its matching merge.
*
* Use a fresh recorder (or call `clear()`) for each independent recording;
* do not split a fork's branch events across batches. Afterwards,
* `getSteps(drillPath?)` reads the built projection without replaying it.
* Live consumers should attach this recorder instead of calling this
* method on every update.
*
* @example
* const rec = runStepRecorder();
* rec.ingestDomainEvents(savedEvents);
* const steps = rec.getSteps();
*/
ingestDomainEvents(events) {
// BoundaryRecorder emits N fork.branch events per fork (all sharing
// parent+ts); onFork expects ONE event carrying all children. Preserve
// the recorded first occurrence and skip the remaining branch rows.
const forkSeen = new Set();
for (let i = 0; i < events.length; i++) {
const e = events[i];
if (e.type === 'fork.branch') {
const key = `${e.parentSubflowId}@${e.ts}`;
if (forkSeen.has(key))
continue;
forkSeen.add(key);
const children = [];
for (let j = i; j < events.length; j++) {
const f = events[j];
if (f.type === 'fork.branch' &&
f.parentSubflowId === e.parentSubflowId &&
f.ts === e.ts) {
children.push(f.childName);
}
else if (f.ts > e.ts) {
break;
}
}
this.onFork({
parent: e.parentSubflowId,
children,
traversalContext: {
runId: 'replay',
stageId: lastSegment(e.subflowPath.join('/') || ROOT_SUBFLOW_ID),
runtimeStageId: e.runtimeStageId,
stageName: lastSegment(e.subflowPath.join('/') || ROOT_SUBFLOW_ID),
depth: e.depth,
},
});
continue;
}
this.ingestDomainEvent(e);
}
}
/**
* Feed a single recorded `DomainEvent` (from BoundaryRecorder) into
* this recorder as if it had fired live. For a saved event list, use
* `ingestDomainEvents` so a parallel fork's branches stay together.
*
* Live consumers should use `runner.attach(rec)` +
* `rec.subscribe(dispatcher)` instead — the recorder's hooks fire
* naturally during traversal.
*/
ingestDomainEvent(e) {
const traversalContext = {
runId: 'replay',
stageId: lastSegment(e.subflowPath.join('/') || ROOT_SUBFLOW_ID),
runtimeStageId: e.runtimeStageId,
stageName: lastSegment(e.subflowPath.join('/') || ROOT_SUBFLOW_ID),
depth: e.depth,
...(e.subflowPath.length > 1 ? { subflowPath: e.subflowPath.slice(1).join('/') } : {}),
};
switch (e.type) {
case 'run.entry':
this.onRunStart({ payload: e.payload, traversalContext });
break;
case 'run.exit':
this.onRunEnd({ payload: e.payload, traversalContext });
break;
case 'subflow.entry':
this.onSubflowEntry({
name: e.subflowName,
subflowId: e.subflowId,
...(e.description ? { description: e.description } : {}),
traversalContext,
});
break;
case 'subflow.exit':
this.onSubflowExit({
name: e.subflowName,
subflowId: e.subflowId,
traversalContext,
});
break;
case 'fork.branch':
// Replay layer; coalescing is handled at the call-site
// (`ingestDomainEvents`) which groups events by parent+ts and
// calls `onFork` once. A single fork.branch slipping through
// to here is treated as a 1-child fork — degenerate but
// harmless.
this.onFork({
parent: e.parentSubflowId,
children: [e.childName],
traversalContext,
});
break;
case 'decision.branch':
this.onDecision({
decider: e.decider,
chosen: e.chosen,
...(e.rationale ? { rationale: e.rationale } : {}),
traversalContext,
});
break;
case 'loop.iteration':
this.onLoop({
target: e.target,
iteration: e.iteration,
traversalContext,
});
break;
case 'llm.start': {
// Synthesize a typed event matching the dispatcher payload
// shape so `ingestTypedEvent` produces the same react step.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.ingestTypedEvent({
type: 'agentfootprint.stream.llm_start',
payload: {
provider: e.provider,
model: e.model,
...(e.systemPromptChars !== undefined
? { systemPromptChars: e.systemPromptChars }
: {}),
...(e.messagesCount !== undefined ? { messagesCount: e.messagesCount } : {}),
...(e.toolsCount !== undefined ? { toolsCount: e.toolsCount } : {}),
},
meta: {
wallClockMs: e.ts,
runOffsetMs: 0,
runtimeStageId: e.runtimeStageId,
// Strip the ROOT_SUBFLOW_ID prefix for the typed-event
// convention (typed events use the inner path, BoundaryRecorder
// adds the root prefix on ingest).
subflowPath: e.subflowPath.slice(1),
compositionPath: [],
runId: 'replay',
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
});
break;
}
case 'llm.end': {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.ingestTypedEvent({
type: 'agentfootprint.stream.llm_end',
payload: {
content: e.content,
toolCallCount: e.toolCallCount,
usage: e.usage,
...(e.stopReason ? { stopReason: e.stopReason } : {}),
},
meta: {
wallClockMs: e.ts,
runOffsetMs: 0,
runtimeStageId: e.runtimeStageId,
subflowPath: e.subflowPath.slice(1),
compositionPath: [],
runId: 'replay',
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
});
break;
}
// tool.start / tool.end / context.injected — not currently used
// by the projection (tools are paired with the next llm.start;
// context injections decorate steps but don't add new ones).
default:
break;
}
}
// ── Query API ─────────────────────────────────────────────────
/**
* Read-only query — returns the already-built step list filtered to
* `drillPath` scope. O(1) per call when scope is empty; O(N) filter
* otherwise. Composition-vs-leaf root filter is applied so the
* slider semantics match the user's mental model:
*
* - **Leaf root** (single Agent / LLMCall): show `react` steps only.
* - **Composition root** (Sequence / Parallel / Conditional / Loop):
* show composition steps; hide intra-leaf `react` steps.
*
* Drill-down filters by `anchor.subflowPath` prefix and re-applies
* the leaf-vs-composition rule for the drilled scope.
*/
getSteps(drillPath) {
const all = this.store.getAll();
const path = drillPath ?? [];
// Leaf-vs-composition filter — delegated to RootInferrer.
const isLeafRoot = this.rootInferrer.isLeafRoot();
const kindFiltered = isLeafRoot
? all.filter((s) => s.kind === 'react')
: all.filter((s) => s.kind !== 'react');
if (path.length === 0) {
return reseq(kindFiltered);
}
return reseq(kindFiltered.filter((s) => isPathPrefix(path, s.anchor.subflowPath)));
}
// ── Helpers ───────────────────────────────────────────────────
/** Flush any deferred answer-candidate from the buffer. Called by
* `onRunEnd` so a single `answers` step appears for runs that end
* on a leaf exit (no further leaf entries followed). */
flushCandidateAnswer() {
const c = this.answerBuffer.flush();
if (!c)
return;
this.push({
seq: this.entryCount,
kind: 'sequential',
transitions: [{ from: c.frame.subflowId, to: ACTOR_USER, via: 'next', label: 'answers' }],
anchor: {
runtimeStageId: c.runtimeStageId,
subflowPath: c.frame.subflowPath,
},
label: 'answers',
tsMs: c.tsMs,
});
}
}
// ─── Helpers ─────────────────────────────────────────────────────
function parsePrimitiveKind(description) {
if (!description)
return undefined;
const colon = description.indexOf(':');
if (colon < 0)
return undefined;
const prefix = description.slice(0, colon).trim();
return prefix.length > 0 ? prefix : undefined;
}
const AGENT_INTERNAL_STAGES = new Set(['route', 'tool-calls', 'final', 'merge']);
function isAgentInternalStageId(localStageId) {
return AGENT_INTERNAL_STAGES.has(localStageId);
}
/** Renumber `seq` after filtering so consumers see contiguous indices. */
function reseq(steps) {
return steps.map((s, i) => (s.seq === i ? s : { ...s, seq: i }));
}
// Touch unused imports defensively for tree-shaking.
void ROOT_RUNTIME_STAGE_ID;
//# sourceMappingURL=RunStepRecorder.js.map