UNPKG

agentfootprint

Version:

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

578 lines • 26.4 kB
"use strict"; /** * FlowchartRecorder — StepGraph projection over `BoundaryRecorder`. * * Pattern: Pure projection. `attachFlowchart` wires a `BoundaryRecorder` * to the executor + dispatcher; `buildStepGraph` is a fold over * `boundary.getEvents()` that produces the renderer-friendly * `StepGraph` shape Lens (and any other consumer) renders. * * ZERO state machine in this file. ZERO event subscription. * ZERO name-based filters. Everything that decides "what's a * step" lives in BoundaryRecorder via capture-time tags * (`actorArrow`, `slotKind`, `primitiveKind`, `isAgentInternal`). * * Role: Tier 3 observability. Enabled via * `runner.enable.flowchart({ onUpdate })`. The handle exposes: * * handle.getSnapshot() → derived StepGraph (back-compat) * handle.boundary → the underlying BoundaryRecorder * (Lens reads it directly for richer * queries: getSlotBoundaries(), * getEventsByType, etc.) * * Event → StepNode mapping (the entire policy): * * run.entry → StepNode kind='subflow', primitiveKind='Run' * subflow.entry → StepNode kind='subflow' (skipped if isAgentInternal * or slotKind set — * those are sub-components * of the actor arrows) * fork.branch → StepNode kind='fork-branch' * decision.branch → StepNode kind='decision-branch' * llm.start → StepNode kind=actorArrow ('user→llm' | 'tool→llm') * tool.start → StepNode kind='llm->tool' (none for a * `notDispatched` start — a call a paused batch * never dispatched, 9.113.0; its end stashes no * result either) * llm.end terminal → StepNode kind='llm->user' (delivery marker) * loop.iteration → loop-iteration StepEdge * context.injected → attached to NEXT user→llm / tool→llm StepNode * * Result: a one-to-one correspondence between visible scrubbable steps * and DomainEvents. Adding a new event type adds one mapping line here * (or in the pure projection); no state machine, no merging. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.buildStepGraphFromEvents = exports.buildStepGraph = exports.attachFlowchart = void 0; const BoundaryRecorder_js_1 = require("./BoundaryRecorder.js"); // ─── Attach entry point ────────────────────────────────────────────── /** * Attach a live FlowchartRecorder to a runner. * * 1. Creates a `BoundaryRecorder` (the unified domain event log) with * commit tracking, so every boundary records WHERE on the commit * axis it happened. * 2. Attaches it to the executor's FlowRecorder channel via * `runnerAttach` — captures run / subflow / fork / decision / loop. * 3. Subscribes it to the dispatcher — captures llm.* / tool.* / * context.injected. * 4. Wires `onUpdate` so the consumer sees a fresh derived StepGraph * on every event. * * @param getCommitCount where each boundary sits on the run's commit * axis — the runner's `getCommitCount()`, sampled live. Optional only * because a caller may have no runner to sample; omitting it stamps * every boundary `commitIdxBefore: 0`, which reads as "all boundaries * opened at once" and leaves an offline step strip with nothing to * place. Nothing downstream can repair it — the commit log records * what stages wrote, never when a boundary was crossed — so the * runner always passes it. * * @internal Called from `RunnerBase.enable.flowchart`. */ function attachFlowchart(runnerAttach, dispatcher, options = {}, getCommitCount) { const boundary = (0, BoundaryRecorder_js_1.boundaryRecorder)(getCommitCount ? { getCommitCount } : {}); const onUpdate = options.onUpdate; // Wrap the recorder to also re-emit StepGraph after each FlowRecorder // event. Without this, consumers see updates only on dispatcher events // (which fire less often than subflow boundaries). const wrapped = onUpdate ? wrapWithEmit(boundary, () => onUpdate(buildStepGraph(boundary))) : boundary; const offAttach = runnerAttach(wrapped); // Subscribe to typed events. The boundary recorder emits a domain // event per llm/tool/context event and we re-derive the StepGraph. const offDispatcher = dispatcher.on('*', (event) => { // Boundary recorder ingests directly via its own subscribe — but // since we own the lifecycle here, route through it explicitly so // we control the onUpdate timing. // eslint-disable-next-line @typescript-eslint/no-explicit-any boundary.ingestTypedEvent?.(event); onUpdate?.(buildStepGraph(boundary)); }); return { getSnapshot: () => buildStepGraph(boundary), boundary, unsubscribe: () => { offAttach(); offDispatcher(); }, }; } exports.attachFlowchart = attachFlowchart; /** * Wrap a recorder so each FlowRecorder hook also calls `afterEach` with * a fresh snapshot. `afterEach` runs AFTER the wrapped hook returns so * the snapshot reflects the just-applied event. */ function wrapWithEmit(boundary, afterEach) { // Optional chaining (`?.()`) so a BoundaryRecorder that doesn't // implement every hook still works. The previous `!` assertions // assumed all hooks exist; in practice they're optional on the // FlowRecorder interface and may be absent. return { id: boundary.id, onRunStart: (e) => { boundary.onRunStart?.(e); afterEach(); }, onRunEnd: (e) => { boundary.onRunEnd?.(e); afterEach(); }, onSubflowEntry: (e) => { boundary.onSubflowEntry?.(e); afterEach(); }, onSubflowExit: (e) => { boundary.onSubflowExit?.(e); afterEach(); }, onFork: (e) => { boundary.onFork?.(e); afterEach(); }, onDecision: (e) => { boundary.onDecision?.(e); afterEach(); }, onLoop: (e) => { boundary.onLoop?.(e); afterEach(); }, }; } // ─── Pure projection: events → StepGraph ───────────────────────────── /** * Closed set of primitives Lens treats as drill-in containers. * Adding a new primitive: ship its `'Kind:'` description prefix at the * builder's `flowChart('...', ..., 'Kind: …')` call site AND add the * name here. Both sides are required. * * All four core-flow compositions (Sequence, Parallel, Conditional, * Loop) mount child runners' charts DIRECTLY via `addSubFlowChart*` * with `runner.getSpec()` as the subflow — no wrappers, no nested * executors. Each child's `primitiveKind` flows through naturally * from its own root description prefix; no wrapper-inheritance hack * is required. (Earlier releases of Parallel used a `RunBranch` * wrapper that ran branches in a nested executor and had to inherit * the child's kind in its description; that design was retired in * favour of native fork mounting — see `core-flow/README.md` decision 8.) */ const KNOWN_PRIMITIVES = new Set([ 'Agent', 'LLMCall', 'Sequence', 'Parallel', 'Conditional', 'Loop', ]); // FOLD · the one owner of the run's step graph — which scrubbable positions exist and which transitions each lights up // consumers read this and never re-derive it: the recorder handle, its onUpdate, and trace.ts through the offline entry buildStepGraphFromEvents // detached: yes — rebuilt per call from the record and never stored (storing it would be a second redaction surface). /** * Project a `BoundaryRecorder`'s event stream into a `StepGraph`. * * Pure function — no side effects, no recorder mutation, deterministic. * Called on every snapshot request and on every `onUpdate` fire. O(N) * over the event stream; consumer-side memoization (e.g., React's * `useMemo`) is straightforward when needed. * * The mapping is local to each event type: see the mapping table in * the file header. State carried across the fold: * - `iter`: 1-based ReAct iteration counter, incremented on each * `llm.start`. ReAct nodes inherit the current value. * - `pendingInjections`: context.injected events buffered between * LLM calls; flushed onto the next user→llm or tool→llm StepNode. * - `prevReActId`: id of the previous ReAct StepNode for `next`-edge * wiring within an iteration. * - `runStartTs`: wall-clock at run start, for relative offsets. */ function buildStepGraph(boundary) { return buildStepGraphFromEvents(boundary.getEvents()); } exports.buildStepGraph = buildStepGraph; /** * Pure events → StepGraph fold. Same projection as `buildStepGraph`, but from a * flat `DomainEvent[]` rather than a live `BoundaryRecorder` — so an offline * `Trace` (which stores only events) can be rebuilt into a graph for `<Replay>` * without re-running the agent. The graph is always derived, never stored. */ function buildStepGraphFromEvents(events) { const nodes = []; const edges = []; let iter = 0; let pendingInjections = []; let prevReActId; let runStartTs; let activeNodeId; /** * Slot boundaries that fired since the LAST llm boundary; flushed * onto the next user→llm or tool→llm StepNode at its `llm.start` * event. Mirrors how `pendingInjections` works — same "buffer until * the next LLM call consumes it" pattern. * * Cleared on each llm.start (after attribution) AND on llm.end with * actorArrow='llm→tool' (the slots assembled BEFORE the next iteration * may still fire after this point — keep buffering). */ let pendingSlotBoundaries = {}; // Most recent `llm.end` content with toolCalls (actorArrow='llm→tool'). // Stamped onto the NEXT `tool.start` StepNode as `assistantText` — the // reasoning text the LLM emitted alongside its tool_use blocks. let pendingAssistantText; // Most recent `tool.end` result. Stamped onto the NEXT `tool.start`- // following `llm.start` (actorArrow='tool→llm') as `toolResult` — what // the LLM is now seeing. let pendingToolResult; // Track open "subflow" nodes so we can close them on subflow.exit // (set endOffsetMs, exitPayload). Keyed by runtimeStageId — same key // the entry event carries; pause/in-progress subflows simply never // close their entry. const openSubflowsByRuntimeId = new Map(); for (const e of events) { if (runStartTs === undefined) runStartTs = e.ts; const t = e.ts - runStartTs; switch (e.type) { case 'run.entry': { const node = { id: e.runtimeStageId, kind: 'subflow', label: 'Run', startOffsetMs: t, subflowPath: e.subflowPath, primitiveKind: 'Run', isPrimitiveBoundary: false, ...(e.payload !== undefined ? { entryPayload: e.payload } : {}), runtimeStageId: e.runtimeStageId, }; nodes.push(node); openSubflowsByRuntimeId.set(e.runtimeStageId, node); activeNodeId = node.id; break; } case 'run.exit': { const open = openSubflowsByRuntimeId.get(e.runtimeStageId); if (open) { open.endOffsetMs = t; if (e.payload !== undefined) { open.exitPayload = e.payload; } openSubflowsByRuntimeId.delete(e.runtimeStageId); } activeNodeId = undefined; break; } case 'subflow.entry': { // Slot subflows: NOT separate timeline steps — buffer their // entry payload to attach to the next LLM call's StepNode. if (e.slotKind) { pendingSlotBoundaries[slotPropName(e.slotKind)] = { runtimeStageId: e.runtimeStageId, ...(e.payload !== undefined ? { entryPayload: e.payload } : {}), }; break; } // Agent-internal routing: pure plumbing, not a step. if (e.isAgentInternal) break; const node = subflowToStepNode(e, t); nodes.push(node); connectAdjacent(nodes, edges); openSubflowsByRuntimeId.set(e.runtimeStageId, node); activeNodeId = node.id; break; } case 'subflow.exit': { // Slot subflow exit: enrich the buffered slot boundary with // its rendered output (the actual slot content the LLM saw). if (e.slotKind) { const key = slotPropName(e.slotKind); const existing = pendingSlotBoundaries[key]; if (existing) { pendingSlotBoundaries[key] = { ...existing, ...(e.payload !== undefined ? { exitPayload: e.payload } : {}), }; } break; } if (e.isAgentInternal) break; const open = openSubflowsByRuntimeId.get(e.runtimeStageId); if (open) { open.endOffsetMs = t; if (e.payload !== undefined) { open.exitPayload = e.payload; } openSubflowsByRuntimeId.delete(e.runtimeStageId); } break; } case 'fork.branch': { const id = `fork-${e.runtimeStageId}-${e.childName}`; nodes.push({ id, kind: 'fork-branch', label: e.childName, startOffsetMs: t, subflowPath: e.subflowPath, }); break; } case 'decision.branch': { // Agent-internal Route decisions (tool-calls / final) are // wiring, not steps — the actor arrows that follow already // encode the routing observably. Filter them out of the // timeline; the rationale is still in the event log for the // right-pane / commentary to read. if (e.isAgentInternal) break; const id = `decision-${e.runtimeStageId}-${e.chosen}`; nodes.push({ id, kind: 'decision-branch', label: e.chosen, startOffsetMs: t, subflowPath: e.subflowPath, }); break; } case 'loop.iteration': { // Self-edge on the currently active subflow node. If no active // subflow node, drop the edge (edge with no `from` is invalid). if (activeNodeId) { edges.push({ id: `loop-${activeNodeId}-${e.iteration}`, from: activeNodeId, to: activeNodeId, kind: 'loop-iteration', iteration: e.iteration, }); } break; } case 'context.injected': { pendingInjections.push({ slot: e.slot, source: e.source, ...(e.sourceId ? { sourceId: e.sourceId } : {}), ...(e.asRole ? { asRole: e.asRole } : {}), ...(e.contentSummary ? { contentSummary: e.contentSummary } : {}), ...(e.reason ? { reason: e.reason } : {}), ...(e.sectionTag ? { sectionTag: e.sectionTag } : {}), ...(e.upstreamRef ? { upstreamRef: e.upstreamRef } : {}), ...(e.retrievalScore !== undefined ? { retrievalScore: e.retrievalScore } : {}), ...(e.rankPosition !== undefined ? { rankPosition: e.rankPosition } : {}), ...(e.budgetTokens !== undefined ? { budgetTokens: e.budgetTokens } : {}), ...(e.budgetFraction !== undefined ? { budgetFraction: e.budgetFraction } : {}), }); break; } case 'llm.start': { iter += 1; const id = `step-llm-start-${e.runtimeStageId}-${iter}`; const injections = pendingInjections; pendingInjections = []; // Flush buffered slot boundaries onto this LLM step. Slot // subflows that fired since the previous LLM end (or run start) // are attributed to THIS call. const slotBoundaries = Object.keys(pendingSlotBoundaries).length > 0 ? pendingSlotBoundaries : undefined; pendingSlotBoundaries = {}; // BoundaryRecorder uses the unicode arrow `→` for the typed // `actorArrow` field; StepNode.kind uses ASCII `->` for legacy // compatibility. Map between them. const stepKind = e.actorArrow === 'tool→llm' ? 'tool->llm' : 'user->llm'; // For tool→llm calls, the most recent tool.end's result is what // the LLM is now seeing — surface it as the step's input data. const toolResult = stepKind === 'tool->llm' ? pendingToolResult : undefined; if (stepKind === 'tool->llm') pendingToolResult = undefined; const node = { id, kind: stepKind, label: stepKind === 'tool->llm' ? 'tool → llm' : 'user → llm', startOffsetMs: t, llmModel: e.model, subflowPath: e.subflowPath, injections, iterationIndex: iter, slotUpdated: 'messages', // Bind to the underlying boundary event's runtimeStageId so // consumers (Lens commentary, custom dashboards) can look up // every event that belongs to this LLM call by id. runtimeStageId: e.runtimeStageId, ...(slotBoundaries ? { slotBoundaries } : {}), ...(toolResult !== undefined ? { toolResult } : {}), }; nodes.push(node); if (prevReActId) { edges.push({ id: `${prevReActId}->${id}`, from: prevReActId, to: id, kind: 'next', }); } prevReActId = id; break; } case 'llm.end': { // The just-prior llm.start's StepNode gets tokens added. // For terminal calls (actorArrow='llm→user') we ALSO append a // separate llm→user delivery marker so the slider has a // distinct "answer delivered" position. const lastStart = findLastByKind(nodes, ['user->llm', 'tool->llm']); if (lastStart) { lastStart.tokens = { in: e.usage.input, out: e.usage.output, }; lastStart.endOffsetMs = t; } if (e.actorArrow === 'llm→user') { const id = `step-llm-end-${e.runtimeStageId}-${iter}`; const node = { id, kind: 'llm->user', label: 'llm → user', startOffsetMs: t, endOffsetMs: t, subflowPath: e.subflowPath, iterationIndex: iter, runtimeStageId: e.runtimeStageId, ...(e.content ? { assistantText: e.content } : {}), }; nodes.push(node); if (prevReActId) { edges.push({ id: `${prevReActId}->${id}`, from: prevReActId, to: id, kind: 'next', }); } prevReActId = id; } else { // actorArrow === 'llm→tool': stash the reasoning text emitted // alongside tool_use blocks; the next tool.start will claim it // as that step's `assistantText`. pendingAssistantText = e.content || undefined; } break; } case 'tool.start': { // A call a paused batch never dispatched (9.113.0) is no step: the // LLM's call never reached a tool. Its bracket stays on the domain // log, marked `notDispatched`, for a viewer that wants to show it. if (e.notDispatched !== undefined) break; const id = `step-tool-start-${e.runtimeStageId}-${e.toolCallId}`; const assistantText = pendingAssistantText; pendingAssistantText = undefined; const node = { id, kind: 'llm->tool', label: `llm → tool (${e.toolName})`, startOffsetMs: t, toolName: e.toolName, subflowPath: e.subflowPath, iterationIndex: iter, slotUpdated: 'tools', runtimeStageId: e.runtimeStageId, ...(assistantText ? { assistantText } : {}), ...(e.args !== undefined ? { toolArgs: e.args } : {}), }; nodes.push(node); if (prevReActId) { edges.push({ id: `${prevReActId}->${id}`, from: prevReActId, to: id, kind: 'next', }); } prevReActId = id; break; } case 'tool.end': { // The settled call's end closes no step, and its sentence is not a // tool's result — the next tool → llm step shows the last REAL one. if (e.notDispatched !== undefined) break; const lastTool = findLastByKind(nodes, ['llm->tool']); if (lastTool) { lastTool.endOffsetMs = t; } // Stash the result for the NEXT tool→llm StepNode (created at // the following llm.start) so the user sees what was sent back. if (e.result !== undefined) pendingToolResult = e.result; break; } } } return { nodes, edges, activeNodeId }; } exports.buildStepGraphFromEvents = buildStepGraphFromEvents; // ─── Helpers ───────────────────────────────────────────────────────── function subflowToStepNode(e, t) { const isAgentBoundary = e.primitiveKind === 'Agent'; const isPrimitiveBoundary = e.primitiveKind !== undefined && KNOWN_PRIMITIVES.has(e.primitiveKind); return { id: e.runtimeStageId, kind: 'subflow', label: e.subflowName, startOffsetMs: t, subflowPath: e.subflowPath, isAgentBoundary, isPrimitiveBoundary, ...(e.primitiveKind ? { primitiveKind: e.primitiveKind } : {}), ...(e.payload !== undefined ? { entryPayload: e.payload } : {}), runtimeStageId: e.runtimeStageId, }; } /** Append a `next` edge between the previous and current node IF both * are ReAct steps (we don't auto-wire subflow-to-subflow edges; those * come from explicit fork/decision events). */ function connectAdjacent(nodes, edges) { if (nodes.length < 2) return; const prev = nodes[nodes.length - 2]; const curr = nodes[nodes.length - 1]; if (!isReActKind(prev.kind) || !isReActKind(curr.kind)) return; edges.push({ id: `${prev.id}->${curr.id}`, from: prev.id, to: curr.id, kind: 'next', }); } function isReActKind(kind) { return (kind === 'user->llm' || kind === 'llm->tool' || kind === 'tool->llm' || kind === 'llm->user'); } function findLastByKind(nodes, kinds) { for (let i = nodes.length - 1; i >= 0; i--) { if (kinds.includes(nodes[i].kind)) return nodes[i]; } return undefined; } /** Map a `slotKind` (kebab-case) to the camelCase property name on * `StepNode.slotBoundaries`. Single source — change here if either * side ever renames. */ function slotPropName(slotKind) { switch (slotKind) { case 'system-prompt': return 'systemPrompt'; case 'messages': return 'messages'; case 'tools': return 'tools'; } } //# sourceMappingURL=FlowchartRecorder.js.map