agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
235 lines (234 loc) • 11.7 kB
TypeScript
/**
* 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.
*/
import type { CombinedRecorder } from 'footprintjs';
import type { EventDispatcher } from '../../events/dispatcher.js';
import { BoundaryRecorder, type DomainEvent } from './BoundaryRecorder.js';
/**
* One node in the step-level flowchart. Node kind drives rendering
* (actor icon, color). ReAct steps carry token + tool details; topology
* nodes (subflow / fork-branch / decision-branch) mirror the footprintjs
* composition events and exist so composition structure (Loop, Parallel,
* Conditional, Swarm) stays visible in the graph.
*/
export interface StepNode {
readonly id: string;
readonly kind: 'user->llm' | 'llm->tool' | 'tool->llm' | 'llm->user' | 'subflow' | 'fork-branch' | 'decision-branch';
readonly label: string;
readonly startOffsetMs: number;
readonly endOffsetMs?: number;
/** LLM step: token usage of the call that bounded this step. */
readonly tokens?: {
readonly in: number;
readonly out: number;
};
/** llm->tool / tool->llm: the tool name. */
readonly toolName?: string;
/** user->llm / tool->llm: the model that was invoked. */
readonly llmModel?: string;
/** Decomposition of the underlying subflowId (rooted under '__root__'). */
readonly subflowPath: readonly string[];
/** Context injections attributed to this step (LLM steps only). */
readonly injections?: readonly ContextInjection[];
/** 1-based ReAct iteration this step belongs to. Undefined for
* topology / composition nodes. */
readonly iterationIndex?: number;
/** Which slot the step's input updated. ReAct steps only. */
readonly slotUpdated?: 'system-prompt' | 'messages' | 'tools';
/** True ONLY for `subflow` StepNodes whose primitiveKind is `'Agent'`.
* Narrow flag for callers that distinguish ReAct agents from other
* composition primitives (cost / iteration / token attribution). */
readonly isAgentBoundary?: boolean;
/** Primitive kind from the subflow root description prefix
* (`'Agent'` / `'LLMCall'` / `'Sequence'` / etc.). */
readonly primitiveKind?: string;
/** True for `subflow` StepNodes representing any KNOWN primitive
* (Agent / LLMCall / Sequence / Parallel / Conditional / Loop) —
* drives Lens's drill-in container treatment. */
readonly isPrimitiveBoundary?: boolean;
/** `inputMapper` payload at the subflow's entry. Subflow nodes only. */
readonly entryPayload?: unknown;
/** Subflow shared state at exit. Subflow nodes only.
* Undefined for in-progress / paused subflows. */
readonly exitPayload?: unknown;
/** LLM's text content. Set on `llm->tool` (the reasoning emitted with the
* tool_use blocks) and on `llm->user` (the terminal answer). */
readonly assistantText?: string;
/** Tool input arguments the LLM produced. Set on `llm->tool` from the
* matching `tool.start` event payload. */
readonly toolArgs?: unknown;
/** Tool result returned to the LLM. Set on `tool->llm` from the
* preceding `tool.end` event payload. */
readonly toolResult?: unknown;
/** Stable per-execution key — same `runtimeStageId` Trace view uses. */
readonly runtimeStageId?: string;
/**
* Slot boundary payloads composed for THIS LLM step.
*
* Set ONLY for `kind === 'user->llm'` and `kind === 'tool->llm'`
* StepNodes — the moments where context flows INTO the LLM. Each
* entry carries the slot subflow's `inputMapper` result (entryPayload)
* and rendered slot output (exitPayload).
*
* Attribution: any slot subflow that fired BETWEEN the previous LLM
* end (or run start) and THIS LLM start is attributed to this call.
* Done at projection time over `boundary.getEvents()`; no consumer-
* side correlation required.
*
* Lens uses this to make the 3 slot rows inside the LLM card
* clickable — clicking a slot reveals its entry/exit payloads in
* the right-pane detail panel without needing direct BoundaryRecorder
* access.
*/
readonly slotBoundaries?: {
readonly systemPrompt?: SlotBoundary;
readonly messages?: SlotBoundary;
readonly tools?: SlotBoundary;
};
}
/** One slot's boundary pair attributed to a specific LLM step. */
export interface SlotBoundary {
/** runtimeStageId of the slot subflow execution. */
readonly runtimeStageId: string;
/** `inputMapper` payload — the data the slot was COMPOSED FROM
* (RAG hits, skill content, user message, tool result, etc). */
readonly entryPayload?: unknown;
/** Subflow shared state at exit — the rendered slot content
* (system prompt string, messages array, tools array). */
readonly exitPayload?: unknown;
}
/** Consumer-facing context injection (5 axes of context engineering). */
export interface ContextInjection {
readonly slot: 'system-prompt' | 'messages' | 'tools';
readonly asRole?: 'system' | 'user' | 'assistant' | 'tool';
readonly source: string;
readonly sourceId?: string;
readonly contentSummary?: string;
readonly reason?: string;
readonly sectionTag?: string;
readonly upstreamRef?: string;
readonly retrievalScore?: number;
readonly rankPosition?: number;
readonly budgetTokens?: number;
readonly budgetFraction?: number;
}
export interface StepEdge {
readonly id: string;
readonly from: string;
readonly to: string;
readonly kind: 'next' | 'loop-iteration' | 'fork-branch' | 'decision-branch';
readonly iteration?: number;
}
export interface StepGraph {
readonly nodes: readonly StepNode[];
readonly edges: readonly StepEdge[];
readonly activeNodeId?: string;
}
export interface FlowchartOptions {
/** Called each time the graph changes; fires synchronously on the
* driving event so the UI updates the moment the structure changes. */
readonly onUpdate?: (graph: StepGraph) => void;
}
export interface FlowchartHandle {
/** Current step graph (derived from boundary events). Safe during or
* after a run. */
readonly getSnapshot: () => StepGraph;
/** Underlying BoundaryRecorder. Use for richer queries — slot data,
* full event log, type-narrowed lookups. The single source of truth
* Lens reads. */
readonly boundary: BoundaryRecorder;
/** Detach from executor + dispatcher. Subsequent events ignored. */
readonly unsubscribe: () => void;
}
/**
* 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`.
*/
export declare function attachFlowchart(runnerAttach: (recorder: CombinedRecorder) => () => void, dispatcher: EventDispatcher, options?: FlowchartOptions, getCommitCount?: () => number): FlowchartHandle;
/**
* 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.
*/
export declare function buildStepGraph(boundary: BoundaryRecorder): StepGraph;
/**
* 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.
*/
export declare function buildStepGraphFromEvents(events: readonly DomainEvent[]): StepGraph;