agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
765 lines (764 loc) • 38.8 kB
TypeScript
/**
* BoundaryRecorder — unified domain event log for an agentfootprint run.
*
* The single source of truth Lens (and any other consumer) reads to
* render a run. Every observable moment in a run is captured as one
* `DomainEvent` in a single ordered stream:
*
* - `run.entry` / `run.exit` — top-level executor.run()
* - `subflow.entry` / `subflow.exit` — every subflow boundary
* - `fork.branch` — one per parallel child
* - `decision.branch` — chosen branch of a Conditional
* - `loop.iteration` — one per back-edge traversal
* - `llm.start` / `llm.end` — LLM provider call lifecycle
* - `tool.start` / `tool.end` — tool execution lifecycle
* - `context.injected` — anything injected into a slot
*
* All events carry `runtimeStageId` (binds with footprintjs Trace view +
* with each other), `subflowPath`, `depth`, and `ts` (wall-clock ms).
* Subflow events are domain-tagged (`slotKind` / `primitiveKind` /
* `isAgentInternal`) so consumers dispatch on tag without re-parsing.
*
* Architecture:
*
* ┌──── footprintjs (domain-agnostic) ────┐
* │ FlowRecorder events (run/subflow/ │ ──┐
* │ fork/decision/loop) │ │
* └───────────────────────────────────────┘ │
* │
* ┌──── agentfootprint dispatcher ─────────┐ │ consumed by
* │ Typed events (llm/tool/context) │ ──┤
* └────────────────────────────────────────┘ │
* ▼
* ┌─── BoundaryRecorder ───┐
* │ one tagged stream of │
* │ DomainEvent │
* └────────────────────────┘
* │
* ▼ consumed by
* ┌────── Lens (UI) ──────┐
* │ Slider / RunFlow / │
* │ NodeDetail / etc. │
* └───────────────────────┘
*
* Why ONE recorder: Lens scrub axis, run-flow graph, slot rows inside
* the LLM card, right-pane detail panel, commentary panel — every UI
* surface reads from the SAME stream. Adding a new domain event = one
* tagged emit + one render shape. No state machines spread across
* renderers, no merging of multiple sources, no name-based filter lists.
*
* Naming: `runtimeStageId` is footprintjs's primitive (path-prefixed +
* `#executionIndex`). `subflowPath` is rooted under the synthetic
* `'__root__'`. `slotKind` / `primitiveKind` are agent-domain. The
* design follows the React Fiber + OpenTelemetry pattern:
* **producers self-describe; consumers dispatch on type**.
*
* WIRING — THREE PARTS, ALL AT RECORD TIME
* ────────────────────────────────────────
* This recorder hears half a run per connection, and the half it misses
* cannot be reconstructed afterwards. All three go on BEFORE `run()`:
*
* 1. `runner.attach(boundary)` — the footprintjs control-flow channel:
* run / subflow entry and exit, forks, decisions, loops. Without it
* there are no boundaries at all.
* 2. `boundary.subscribe(runner)` — the agentfootprint typed stream:
* `llm.*`, `tool.*`, `context.injected`. Without it the boundaries
* have no content: no model calls, no tool calls, no injections.
* 3. `{ getCommitCount }` — where each boundary sits on the commit
* axis. Without it every event is stamped `commitIdxBefore: 0` and
* the step strip cannot be rebuilt from the recording later. This
* one fails SILENTLY — the events all look fine.
*
* There is no after-the-fact fix for any of the three: a completed run
* leaves a snapshot and a commit log, and neither says where a boundary
* opened relative to the commits. `recordRun(runner)` does all three and
* hands back `{ snapshot, events, structure }` — reach for it first, and
* wire by hand only when you need a shape it doesn't produce.
*
* @example
* ```typescript
* import { boundaryRecorder, EventDispatcher } from 'agentfootprint';
*
* const boundary = boundaryRecorder({
* getCommitCount: () => executor.getCommitCount(), // the commit axis
* });
* const dispatcher = new EventDispatcher();
* executor.attachCombinedRecorder(boundary); // wires FlowRecorder side
* boundary.subscribe(dispatcher); // wires typed-event side
*
* await executor.run({ input });
*
* for (const e of boundary.getEvents()) {
* switch (e.type) {
* case 'run.entry': renderRoot(e); break;
* case 'subflow.entry': if (e.slotKind) renderSlotRow(e);
* else if (e.primitiveKind) renderPrimitive(e);
* break;
* case 'llm.start': renderLLMCall(e); break;
* // ...
* }
* }
* ```
*/
import { CommitRangeIndex } from 'foottrace';
import type { CombinedRecorder, FlowDecisionEvent, FlowForkEvent, FlowLoopEvent, FlowSubflowEvent, TraversalContext } from 'footprintjs';
interface FlowRunEvent {
readonly payload?: unknown;
readonly traversalContext?: TraversalContext;
}
interface FlowRunFailedEvent {
readonly structuredError: {
readonly message: string;
};
readonly traversalContext?: TraversalContext;
}
import type { AgentfootprintEvent } from '../../events/registry.js';
import type { ToolEndPayload, ToolStartPayload } from '../../events/payloads.js';
import type { Unsubscribe } from '../../events/dispatcher.js';
import type { ContextSlot } from '../../events/types.js';
/** Fields every domain event carries. */
interface DomainEventBase {
/** Stable per-execution key (footprintjs primitive). For run events it
* is `'__root__#0'`; subflow events use the parent stage's runtimeStageId
* at mount; typed events use the firing stage's runtimeStageId. */
readonly runtimeStageId: string;
/** Decomposition of `subflowId` into segments, rooted under `'__root__'`. */
readonly subflowPath: readonly string[];
/** Depth in the run tree — root = 0, top-level subflow = 1, etc. */
readonly depth: number;
/** Wall-clock ms at capture time. */
readonly ts: number;
/** Commit count when this event fired. 0 if the recorder was
* constructed without `getCommitCount` (legacy mode). The boundary
* RANGE for an (entry, exit) pair is `[entry.commitIdxBefore,
* exit.commitIdxBefore]`. Phase 5 Layer 2 — see
* `docs/design/boundary-commit-ranges.md`. */
readonly commitIdxBefore: number;
/** RESERVED for future event types that trigger engine writes.
* CURRENT BEHAVIOR: always equals `commitIdxBefore` for every event
* emitted by today's BoundaryRecorder. Observer events don't write
* to scope, so the executor's commit count doesn't change between
* the moment the event is sampled and the moment it's recorded.
* Consumers should currently treat this as identical to
* `commitIdxBefore`; do NOT rely on it being strictly greater.
* The field exists for forward compatibility — if a future
* observer pattern triggers commits during its handler, this is
* where the post-effect count will land. */
readonly commitIdxAfter: number;
}
export interface DomainRunEvent extends DomainEventBase {
readonly type: 'run.entry' | 'run.exit';
readonly payload?: unknown;
/** Always `true` for run events — convenience flag for filter callers. */
readonly isRoot: true;
}
export interface DomainSubflowEvent extends DomainEventBase {
readonly type: 'subflow.entry' | 'subflow.exit';
/** Path-prefixed engine id (matches `FlowSubflowEvent.subflowId`). */
readonly subflowId: string;
/** Last segment of `subflowId` — convenience for leaf-name grouping. */
readonly localSubflowId: string;
readonly subflowName: string;
/** Build-time description from the subflow root (`'<Kind>: <detail>'`). */
readonly description?: string;
/** Parsed `'<Kind>:'` prefix — `'Agent'`, `'LLMCall'`, `'Sequence'`, etc. */
readonly primitiveKind?: string;
/** Set ONLY for the 3 input-slot subflows (sf-system-prompt / sf-messages / sf-tools). */
readonly slotKind?: ContextSlot;
/** True for Agent state-machine routing/wrapper subflows (route, tool-calls, final, merge). */
readonly isAgentInternal: boolean;
/** `inputMapper` result on entry; subflow shared state on exit. */
readonly payload?: unknown;
}
export interface DomainForkBranchEvent extends DomainEventBase {
readonly type: 'fork.branch';
readonly parentSubflowId: string;
readonly childName: string;
}
export interface DomainDecisionBranchEvent extends DomainEventBase {
readonly type: 'decision.branch';
readonly decider: string;
readonly chosen: string;
readonly rationale?: string;
/**
* `true` when this decision comes from one of the Agent's internal
* routing stages (e.g., the ReAct `Route` decider that picks
* `tool-calls` vs `final`). Filtered out of the timeline by
* `buildStepGraph` — the actor arrows that follow already encode
* the routing observably (`llm→tool` vs `llm→user`).
*
* `false` when the decision comes from a consumer-defined
* `Conditional` primitive — those ARE meaningful timeline steps.
*/
readonly isAgentInternal: boolean;
}
export interface DomainLoopIterationEvent extends DomainEventBase {
readonly type: 'loop.iteration';
readonly target: string;
readonly iteration: number;
}
/**
* Composition boundary event — fired for every composition primitive
* (Parallel / Sequence / Loop / Conditional). Mirrors `subflow.entry/exit`
* but for the COMPOSITION wrapper itself (the box that contains the
* branches / steps / iterations / chosen-branch).
*
* This pair OPENS and CLOSES a boundary range in `boundaryIndex`. Child
* subflows that fire between the pair nest naturally inside the
* composition's range.
*
* The `runtimeStageId` is the composition's own per-execution id —
* SAME format as any other runtimeStageId, with `#executionIndex`. The
* `kind` discriminates which composition primitive this is.
*
* For the Lens compound time axis, this group is what collapses
* parallel branches into ONE slider position at the parent's drill
* level. Drill into the composition to see its children as positions.
*/
export interface DomainCompositionEvent extends DomainEventBase {
readonly type: 'composition.start' | 'composition.end';
readonly kind: 'Parallel' | 'Sequence' | 'Loop' | 'Conditional';
readonly compositionId: string;
readonly name: string;
/** On `composition.end`, the exit status reported by the composition. */
readonly status?: 'ok' | 'err' | 'break' | 'budget_exhausted';
readonly durationMs?: number;
}
/**
* The 4 actor arrows of a ReAct cycle. Tagged on `llm.start` / `llm.end`
* at capture time so consumers (slider, run-flow renderer) dispatch by
* `event.actorArrow` instead of running their own state machine.
*
* - `'user→llm'` — first LLM call, or any LLM call NOT preceded by a
* tool result (assembled-context delivery to the model).
* - `'tool→llm'` — LLM call that follows a tool's result (the next
* iteration of a ReAct loop).
* - `'llm→tool'` — `llm.end` whose `toolCallCount > 0` (the LLM is
* requesting tool execution).
* - `'llm→user'` — `llm.end` with `toolCallCount === 0` (terminal
* response delivered to the user).
*/
export type ActorArrow = 'user→llm' | 'tool→llm' | 'llm→tool' | 'llm→user';
export interface DomainLLMStartEvent extends DomainEventBase {
readonly type: 'llm.start';
readonly model: string;
readonly provider: string;
readonly systemPromptChars?: number;
/**
* The ASSEMBLED system prompt, verbatim as sent (9.88.0) — carried through
* from `stream.llm_start`, so present under exactly the same condition:
* the run was built with `recordSystemPrompt: true`. It used to be dropped
* here, which meant the one opt-in that puts the prompt on the record
* reached every sink EXCEPT the ordered boundary stream a replay reads.
*
* PRIVACY: it carries whatever its pieces carried. See
* `payloads.ts` · `LLMStartPayload`.
*/
readonly systemPromptText?: string;
readonly messagesCount?: number;
readonly toolsCount?: number;
/**
* The tool CATALOG the model saw for this call — `{ name, description }` per
* tool, in request order (9.88.0, carried through from `stream.llm_start`;
* absent when the call had no tools). `toolsCount` above says HOW MANY, and
* a count is not an answer to "which tools was it choosing between?".
*/
readonly tools?: readonly {
readonly name: string;
readonly description?: string;
}[];
/** Capture-time classification: `'user→llm'` for the first call or any
* call not preceded by a tool result; `'tool→llm'` after a tool result. */
readonly actorArrow: 'user→llm' | 'tool→llm';
}
export interface DomainLLMEndEvent extends DomainEventBase {
readonly type: 'llm.end';
readonly content: string;
readonly toolCallCount: number;
readonly usage: {
readonly input: number;
readonly output: number;
};
readonly stopReason?: string;
/** Capture-time classification: `'llm→tool'` when the LLM requested
* tools (`toolCallCount > 0`); `'llm→user'` for terminal delivery. */
readonly actorArrow: 'llm→tool' | 'llm→user';
}
export interface DomainToolStartEvent extends DomainEventBase {
readonly type: 'tool.start';
readonly toolName: string;
readonly toolCallId: string;
readonly args?: unknown;
/** 9.113.0 — copied from `stream.tool_start`: a call a paused batch never
* dispatched. The step graph draws no tool step for it and a boundary's
* rollup does not count it. Absent on every other tool event. */
readonly notDispatched?: ToolStartPayload['notDispatched'];
}
export interface DomainToolEndEvent extends DomainEventBase {
readonly type: 'tool.end';
readonly toolCallId: string;
readonly result?: unknown;
readonly durationMs?: number;
readonly error?: boolean;
/** 9.113.0 — copied from `stream.tool_end`: the settled call's bracket
* closing. Its `result` is the library's sentence, not a tool's. */
readonly notDispatched?: ToolEndPayload['notDispatched'];
}
export interface DomainContextInjectedEvent extends DomainEventBase {
readonly type: 'context.injected';
readonly slot: ContextSlot;
readonly source: string;
readonly sourceId?: string;
readonly asRole?: 'system' | 'user' | 'assistant' | 'tool';
readonly contentSummary?: string;
readonly reason?: string;
readonly sectionTag?: string;
readonly upstreamRef?: string;
readonly retrievalScore?: number;
readonly rankPosition?: number;
/** Tokens consumed by this injection (from `budgetSpent.tokens`). */
readonly budgetTokens?: number;
/** Fraction of slot cap consumed (from `budgetSpent.fractionOfCap`). */
readonly budgetFraction?: number;
}
/** Discriminated union covering every observable moment in a run. */
export type DomainEvent = DomainRunEvent | DomainSubflowEvent | DomainCompositionEvent | DomainForkBranchEvent | DomainDecisionBranchEvent | DomainLoopIterationEvent | DomainLLMStartEvent | DomainLLMEndEvent | DomainToolStartEvent | DomainToolEndEvent | DomainContextInjectedEvent;
/**
* The `DomainEvent` fields that carry captured run CONTENT — what the
* agent read, produced, and handed across a boundary — as opposed to
* run STRUCTURE (where a boundary was, when, of what kind). These are
* the fields `snapshot: 'lean'` drops.
*
* They are also exactly the fields `buildStepGraphFromEvents` turns
* into StepNode content (`entryPayload` / `exitPayload` /
* `contentSummary` / `assistantText` / `toolArgs` / `toolResult`) —
* which is why dropping them is opt-in rather than the default.
*
* When you add a field to a `Domain*Event`, decide:
* - Does it hold data the agent handled (unbounded, redaction-
* relevant)? → add it HERE
* - Does it say where/when/what-kind the boundary was? → leave it out
*
* Forgetting to add one means a lean snapshot still ships that content
* — silently, since nothing else references the field.
*
* Two free-text fields deliberately stay OUT of this set: `rationale`
* on `decision.branch` and `reason` on `context.injected`. They are
* short capture-time annotations saying WHY a boundary happened, and a
* lean bundle loses most of its explanatory value without them — but a
* decider that interpolates run values into its rationale does put that
* text in a lean artifact. Lean means "no captured payloads", not "no
* free text at all"; say so wherever lean is offered as a redaction
* story.
*/
declare const CONTENT_FIELDS: readonly ["payload", "args", "result", "content", "contentSummary"];
type ContentField = (typeof CONTENT_FIELDS)[number];
/**
* A `DomainEvent` with its content fields removed — the shape
* `toSnapshot()` emits under `snapshot: 'lean'`.
*
* Distributive by construction (`T extends unknown ? … : never`) so the
* union survives the `Omit` and `type` still narrows for consumers.
*/
type StripContent<T> = T extends unknown ? Omit<T, ContentField> : never;
export type LeanDomainEvent = StripContent<DomainEvent>;
/**
* Per-boundary rollup returned by
* `BoundaryRecorder.aggregateForBoundary` and
* `BoundaryRecorder.aggregateAllBoundaries`. Same shape regardless of
* primitive kind — UIs render the same chip set for every Agent /
* LLMCall / Sequence / Parallel / Conditional / Loop.
*
* Events count toward this rollup when their `subflowPath` is a
* prefix-match of the boundary's `subflowPath`. Nested boundaries
* (e.g., LLMCall inside an Agent) contribute to BOTH rollups.
*
* In-flight boundaries (no `subflow.exit` yet) get partial values;
* `endedAtMs` and `durationMs` are undefined until close.
*/
export interface BoundaryAggregate {
readonly runtimeStageId: string;
readonly subflowId: string;
readonly subflowPath: readonly string[];
/** `'Agent'` / `'LLMCall'` / `'Sequence'` / `'Parallel'` /
* `'Conditional'` / `'Loop'`. Always set on rollups returned by
* `aggregateAllBoundaries` (which filters to primitive boundaries).
* Optional on `aggregateForBoundary` results because the caller may
* request rollup for a non-primitive subflow (rare). */
readonly primitiveKind?: string;
/** Subflow display name (e.g., 'Triage', 'Billing'). */
readonly label: string;
/** Token usage summed across every `llm.end` inside this boundary. */
readonly tokens: {
readonly input: number;
readonly output: number;
};
/** Count of `llm.start` events inside this boundary. */
readonly llmCalls: number;
/** Count of `tool.start` events inside this boundary — a settled call's
* (`notDispatched`, 9.113.0) excluded: it never ran. */
readonly toolCalls: number;
/** Count of `agent.iteration_start` events scoped to this boundary —
* ReAct-loop iterations. Always `0` for non-Agent primitives. */
readonly iterations: number;
/** Wall-clock ms of `subflow.entry`. */
readonly startedAtMs: number;
/** Wall-clock ms of `subflow.exit`. Undefined while in flight. */
readonly endedAtMs?: number;
/** `endedAtMs - startedAtMs`. Undefined while in flight. */
readonly durationMs?: number;
}
/**
* Anything that can hand out every typed event as it fires — the one
* capability `BoundaryRecorder.subscribe()` needs.
*
* A `Runner` satisfies this (`runner.on('*', …)`) and so does the
* internal `EventDispatcher`, which is the point: wiring the recorder's
* typed half never requires reaching past the public runner for a
* dispatcher it doesn't hand out.
*/
export interface TypedEventSource {
on(type: '*', listener: (event: AgentfootprintEvent) => void): Unsubscribe;
}
export interface BoundaryRecorderOptions {
readonly id?: string;
/**
* Live commit-count accessor — `() => runner.getCommitCount()` on an
* agentfootprint runner, or `() => executor.getCommitCount()` on a raw
* footprintjs executor (5.1+). When provided:
* - Every DomainEvent gains `commitIdxBefore` / `commitIdxAfter`.
* - `recorder.boundaryIndex` is populated with open/close ranges
* keyed on each subflow's entry event.
*
* **Omitting it produces a recording whose step strip cannot be
* rebuilt.** Every event is stamped `commitIdxBefore: 0`, so every
* boundary claims to have opened at the same instant; `boundaryIndex`
* is deliberately left EMPTY rather than filled with zero-width
* `[0, 0]` ranges that would read as real. Offline, a consumer
* replaying this bundle sees no positions to place, so the strip stays
* quiet — the honest outcome, and an unrecoverable one: the commit log
* records what each stage WROTE, never when a boundary was crossed
* relative to it, so nothing downstream can derive the axis after the
* fact. There is no error and the events otherwise look complete,
* which is exactly why this is called out here.
*
* Sample it live — a closure over the runner, not a captured number.
* The count is meaningless before the run starts and must be read at
* the moment each boundary fires. Phase 5 Layer 2.
*/
readonly getCommitCount?: () => number;
/**
* How much of each event `toSnapshot()` carries into
* `runtimeSnapshot.recorders`.
*
* - `'full'` (default) — every field, content included. Required by
* `buildStepGraphFromEvents()`: an offline `<Replay>` restores its
* entry/exit payloads, assistant text, tool args and tool results
* from this bundle and nowhere else.
* - `'lean'` — boundary STRUCTURE only. Drops the five content
* fields (`payload`, `args`, `result`, `content`,
* `contentSummary`); keeps every field that says WHERE, WHEN and
* OF WHAT KIND each boundary was, so the commit-range index
* rebuilds range for range offline. Sized on one demo turn (4
* ReAct iterations, 4 LLM calls, 3 tool calls, run end to end on
* a mock provider): the bundle this method returned was 69.7 KB
* raw / 4.6 KB gzipped, its lean projection 21.7 KB / 1.9 KB —
* content was 69% of the raw bytes, and a consumer rebuilding
* only the index reads none of it. Same run measured twice; that
* turn's content was the entry/exit payloads, since only the
* boundary side of this recorder was attached — a run that also
* `subscribe()`s the typed-event side carries tool args, tool
* results and assistant text in there too. (The replay fixture
* the suite measures reports a much larger full bundle — it
* stands in for each subflow's missing entry seed with that
* subflow's final state, so it serializes the state twice per
* boundary. Don't quote it.)
*
* Same reasoning as `BoundaryRangeLabel`: a stored artifact shouldn't
* become a second, redaction-bypassing copy of everything the agent
* read and wrote. Consumers that DO want content join back on
* `runtimeStageId` against the run's own snapshot.
*
* `'full'` stays the default because this bundle has carried content
* since it shipped (v2.x, 2026-04-27) and offline consumers depend on
* that silently — a lean default would quietly empty their replays.
*/
readonly snapshot?: 'full' | 'lean';
/**
* Keep raw embedding vectors in captured payloads (8.20.0). Default
* `false`: every `embedding` / `embeddings` field in a boundary payload is
* replaced with its `{ dims, norm }` summary at capture time.
*
* The default exists because of a field measurement: the memory-read
* subflow's boundary output carries each retrieved entry's full vector,
* and one retrieval turn's recording weighed 2.76 MB — ~1.1 MB of it
* embedding floats no consumer of a recording reads. The retrieval
* evidence a debugger needs (score, passage, document, rejected
* candidates) does not live in those floats and is untouched.
*
* Set `true` only when a consumer genuinely replays vectors (say, to
* re-rank offline) — the recording grows by the full vector bytes again.
*/
readonly recordEmbeddings?: boolean;
}
/**
* Stripped projection used as the LABEL for the commit-range index.
* Intentionally OMITS `payload` (security panel review YELLOW #1):
* `boundaryIndex.enclosing()` queries should not bypass redaction by
* exposing raw scope payloads through the range index. Consumers
* needing payload can join on `runtimeStageId` with the full event
* stream via `getEvents()` (which IS subject to redaction policy).
*/
export interface BoundaryRangeLabel {
readonly type: 'subflow.entry' | 'run.entry' | 'composition.start';
readonly runtimeStageId: string;
readonly subflowPath: readonly string[];
readonly depth: number;
readonly ts: number;
/** Set on subflow entries; undefined on the synthetic run-root entry. */
readonly subflowId?: string;
readonly localSubflowId?: string;
readonly subflowName?: string;
readonly description?: string;
readonly primitiveKind?: string;
readonly slotKind?: ContextSlot;
readonly isAgentInternal?: boolean;
/** Composition primitive (Parallel/Sequence/Loop/Conditional) when the
* range was opened by a `composition.start` event. */
readonly compositionKind?: 'Parallel' | 'Sequence' | 'Loop' | 'Conditional';
readonly compositionName?: string;
}
/** Factory — matches the `inOutRecorder()` / `topologyRecorder()` style. */
export declare function boundaryRecorder(options?: BoundaryRecorderOptions): BoundaryRecorder;
/**
* Unified domain event recorder. Implements `CombinedRecorder` so it can
* attach to the executor's FlowRecorder channel; exposes `subscribe()`
* to wire to the agentfootprint typed-event dispatcher.
*
* v5: composes a `SequenceStore<DomainEvent>` (storage) instead of
* extending the deprecated `SequenceStore<T>` base. Time-travel
* utilities (`getEntryRanges`, `accumulate`) are accessed through the
* store via the public read API on this class.
*/
export declare class BoundaryRecorder implements CombinedRecorder {
readonly id: string;
/** Composition: storage shelf. */
private readonly store;
/**
* Phase 5 Layer 2 — interval index over commit indices, populated
* live as boundary entry/exit pairs fire. Consumers (Lens) read
* `enclosing(commitIdx)` for breadcrumbs and `overlapping(slice)`
* for time-range queries. Empty when `getCommitCount` is not
* injected. See `docs/design/boundary-commit-ranges.md`.
*/
readonly boundaryIndex: CommitRangeIndex<BoundaryRangeLabel>;
/** Open-range tokens keyed by `runtimeStageId` so the matching exit
* can close the correct range. Pure side-table; cleared on runId
* reset. Not exposed externally. */
private readonly openTokens;
/** Live commit-count accessor injected by the runner. Sanitized
* (NaN/Infinity/negative → 0) before use. */
private readonly getCommitCount;
/** True when `getCommitCount` was explicitly injected. In LEGACY
* MODE (false), `boundaryIndex` is intentionally NOT populated —
* zero-width [0,0] ranges would mislead consumers querying the
* index. Multi-panel review flagged this footgun. */
private readonly hasCommitTracking;
/** Snapshot verbosity — see `BoundaryRecorderOptions.snapshot`. Only
* `toSnapshot()` reads it; the live event stream from `getEvents()`
* is always full, so in-process consumers are never affected. */
private readonly snapshotMode;
/** See `BoundaryRecorderOptions.recordEmbeddings`. Applied at CAPTURE
* time, so the summary is what every projection (live stream, snapshot,
* lean) sees — one truth, not per-reader rewrites. */
private readonly keepEmbeddings;
/**
* Tracks whether the most recent `llm.end` had toolCalls. Used to
* classify the NEXT `llm.start` as `'tool→llm'` (vs `'user→llm'` if
* there's no pending tool result). Reset on `clear()` and on every
* `llm.start` event after the classification is applied.
*/
private prevLLMEndHadTools;
/**
* Run-boundary observer — fires resetForNewRun() when
* traversalContext.runId changes between events AND no boundary is
* currently open. The "no open boundary" gate distinguishes:
*
* - **Legitimate new run** — consumer reuses one recorder across
* sequential `executor.run()` calls. All prior boundaries closed
* before the second run began; openTokens is empty when the new
* runId arrives → safe to wipe state so the second run doesn't
* alias with the first.
* - **Composition sub-run** — primitives like `LLMCall`, `Sequence`,
* and `Parallel` internally spawn their own `FlowChartExecutor`
* instances. Each sub-executor mints a NEW runId. When that
* sub-executor fires events on the SHARED recorder, the recorder
* is still inside the parent run — `openTokens` is non-empty.
* Resetting here would wipe the parent's boundary index mid-run
* (the bug Layer 4 surfaced in agentfootprint-lens fanout).
*
* The `openTokens.size === 0` check is the cleanest semantic signal:
* if nothing is in-flight, a runId change means "the consumer started
* fresh"; if something is open, the new runId is from a sub-executor
* nested inside the still-ongoing parent.
*/
private readonly runIdGuard;
constructor(options?: BoundaryRecorderOptions);
/** Boundary payloads keep a vector's shape, not its bytes — see
* `recordEmbeddings`. Copy-on-write: payloads without embeddings pass
* through by reference. */
private capturePayload;
/**
* Reset all transient state.
*
* **Composition-safe gate (Phase 5 Layer 4):** if `openTokens.size > 0`
* the call is a no-op. Rationale: `FlowChartExecutor.run()` calls
* `r.clear?.()` on every attached recorder during its pre-run loop.
* When agentfootprint composition primitives (LLMCall, Sequence,
* Parallel, etc.) propagate the parent's recorders to nested
* sub-executors, EACH sub-executor's pre-run clear loop calls
* `clear()` on the SHARED parent recorder mid-run — wiping live
* parent state. The `openTokens.size > 0` check distinguishes:
*
* - **Legitimate reset** — consumer or executor calls `clear()`
* when no boundary is in-flight (`openTokens` empty). Safe to
* wipe; the recorder is idle.
* - **Composition wipe** — sub-executor's pre-run clear fires
* while the parent has open boundaries (`openTokens` non-empty).
* Skip the wipe; the parent's state must be preserved.
*
* If a consumer needs to forcibly wipe state even with open tokens
* (e.g., manual recovery after a crashed run), pair `clear()` with
* an explicit `forceClear()` (TODO — add when the use case shows up;
* today the recorder lifecycle pattern is "one recorder per logical
* run" so leaked tokens shouldn't occur).
*/
clear(): void;
private observeRunId;
onRunStart(event: FlowRunEvent): void;
onRunEnd(event: FlowRunEvent): void;
onRunFailed(event: FlowRunFailedEvent): void;
onSubflowEntry(event: FlowSubflowEvent): void;
onSubflowExit(event: FlowSubflowEvent): void;
onFork(event: FlowForkEvent): void;
onDecision(event: FlowDecisionEvent): void;
onLoop(event: FlowLoopEvent): void;
/**
* Subscribe to the run's typed events and emit a domain event for each
* `llm.*` / `tool.*` / `context.injected` one. This is the recorder's
* SECOND half — `runner.attach(...)` supplies the boundaries, this
* supplies what happened inside them. Wire both, before the run.
*
* Takes anything that offers a wildcard subscription, which a public
* `Runner` does as readily as the internal `EventDispatcher` — so a
* consumer never has to reach past `runner.on` for a dispatcher it
* isn't given.
*
* Returns an unsubscribe function; safe to call multiple times (each
* call adds a new subscription). Most consumers call this once at
* recorder construction and dispose with the returned function.
*/
subscribe(source: TypedEventSource): Unsubscribe;
private ingestTypedEvent;
/** All events in capture order (the canonical projection). */
getEvents(): DomainEvent[];
/** Type-narrowed lookup: all events of one kind. */
getEventsByType<T extends DomainEvent['type']>(type: T): Extract<DomainEvent, {
type: T;
}>[];
/** All boundary events (run + subflow, entry + exit interleaved). */
getBoundaries(): (DomainRunEvent | DomainSubflowEvent)[];
/** Just the entry-phase boundary events — the "step list" timeline. */
getSteps(): (DomainRunEvent | DomainSubflowEvent)[];
/** Subset of `getSteps()` excluding agent-internal routing subflows. */
getVisibleSteps(): (DomainRunEvent | DomainSubflowEvent)[];
/** Entry/exit pair for one chart execution by `runtimeStageId`. */
getBoundary(runtimeStageId: string): {
entry?: DomainRunEvent | DomainSubflowEvent;
exit?: DomainRunEvent | DomainSubflowEvent;
};
/** Convenience for the outermost `__root__` pair. */
getRootBoundary(): {
entry?: DomainRunEvent;
exit?: DomainRunEvent;
};
/** Subflow events grouped by the 3 input slots — for slot-row rendering. */
getSlotBoundaries(): {
systemPrompt: DomainSubflowEvent[];
messages: DomainSubflowEvent[];
tools: DomainSubflowEvent[];
};
/**
* Roll up the event stream for ONE primitive boundary (Agent /
* LLMCall / Sequence / Parallel / Conditional / Loop) into per-
* boundary totals — tokens, llm calls, tool calls, iterations,
* cache hits, duration.
*
* Pure projection over `getEvents()`. Events are attributed to a
* boundary when their `subflowPath` is a **prefix-match** of the
* boundary's path — so a nested `LLMCall` inside an `Agent` rolls
* up into BOTH (LLMCall total + Agent total).
*
* Works mid-run (the boundary's `subflow.exit` may not have fired
* yet — `endedAtMs` / `durationMs` are undefined in that case).
* Works post-run.
*
* Multi-consumer story: this is the single source of rollup truth
* for Lens, CLI live monitors, Sentry breadcrumbs, OTel exporters,
* dashboards. Domain math (what counts as an "iteration"? does
* cache hit count separately from llmCalls?) lives HERE — every
* consumer hooks up; nobody re-implements.
*
* @param runtimeStageId The boundary's runtimeStageId (the same id
* carried by `StepNode.runtimeStageId` for primitive subflows).
* @returns The rollup, or `undefined` if no `subflow.entry` event
* matches `runtimeStageId`.
*/
aggregateForBoundary(runtimeStageId: string): BoundaryAggregate | undefined;
/**
* Roll up every primitive boundary in the run into one rollup each,
* in the order their `subflow.entry` events fired. Top-level multi-
* agent UIs call this once per render to populate per-agent chips.
*
* Filters to `primitiveKind`-tagged subflows ONLY (Agent / LLMCall /
* Sequence / Parallel / Conditional / Loop). Slot subflows
* (`sf-system-prompt` / `sf-messages` / `sf-tools`) are NOT
* boundaries in this sense — they're context-engineering machinery,
* not user-facing rollup units.
*/
aggregateAllBoundaries(): readonly BoundaryAggregate[];
/** Snapshot bundle — included in `executor.getSnapshot()` if the
* executor implements the snapshot extension protocol.
*
* Under `snapshot: 'lean'` the bundle carries structure only, and
* `meta.mode` says so. That field is the whole point: an offline
* consumer holding a lean bundle can then SAY "this recording carries
* structure only" instead of drawing empty detail panels, because
* `buildStepGraphFromEvents()` restores step content from this bundle
* and nowhere else. `description` still carries the same fact in
* prose for a human reading the JSON — but prose is not something
* code branches on, and the readers that had to string-match it
* didn't.
*
* `meta` reaches `getSnapshot().recorders[i].meta` on footprintjs
* 9.12+, which copies it through; older engines rebuild the row
* without it and the field is dropped in transit. It is always on the
* value THIS method returns, so a producer holding the recorder
* (`recordRun`) can read it either way.
*
* `data` is annotated rather than inferred: a lean event is a
* structural SUPERtype of a full one, so inference collapses the
* union to `LeanDomainEvent[]` and full-mode callers silently lose
* `payload` / `content` / … from the type. Narrow on the mode you
* configured. */
toSnapshot(): {
name: string;
description: string;
preferredOperation: 'translate';
data: DomainEvent[] | LeanDomainEvent[];
meta: {
readonly mode: 'full' | 'lean';
};
};
}
export {};