UNPKG

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