agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
227 lines (226 loc) • 10.5 kB
TypeScript
/**
* 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 type { CombinedRecorder, FlowDecisionEvent, FlowForkEvent, FlowLoopEvent, FlowSubflowEvent, TraversalContext } from 'footprintjs';
interface FlowRunEvent {
readonly payload?: unknown;
readonly traversalContext?: TraversalContext;
}
import type { AgentfootprintEvent } from '../../events/registry.js';
import type { EventDispatcher, Unsubscribe } from '../../events/dispatcher.js';
import type { DomainEvent } from './BoundaryRecorder.js';
/**
* One slider position. The smallest scrubable unit of the run.
*
* `transitions` is 1+ — fan-out / merge steps light up multiple
* transitions at once; sequential / decide / react steps light up
* exactly one. Renderers iterate `transitions` to highlight edges;
* details panels read `anchor.runtimeStageId`.
*/
export interface RunStep {
/** 0-based slider index (matches array position in `getSteps()`). */
readonly seq: number;
readonly kind: RunStepKind;
readonly transitions: readonly RunStepTransition[];
/**
* Per-step key — required by `SequenceStore<T>` for time-travel
* utilities (`getEntriesForStep`, `getEntryRanges`). Mirrors
* `anchor.runtimeStageId`; both fields point at the same value.
* Top-level placement satisfies the recorder's storage contract.
*/
readonly runtimeStageId: string;
/** Anchor for commentary highlight + details pane lookup. */
readonly anchor: {
readonly runtimeStageId: string;
readonly subflowPath: readonly string[];
};
/** Human label — short, kind-specific. */
readonly label: string;
/** Wall-clock ms at which this step occurred. */
readonly tsMs: number;
/** Kind-specific decoration. Discriminate on `kind`. */
readonly meta?: RunStepMeta;
}
export type RunStepKind = 'sequential' | 'fork' | 'merge' | 'decide' | 'iteration' | 'iteration-exit' | 'react';
export interface RunStepTransition {
readonly from: string;
readonly to: string;
readonly via: 'next' | 'fork-branch' | 'decision-branch' | 'loop-iteration' | 'actor-arrow';
readonly label?: string;
}
export type RunStepMeta = {
readonly kind: 'decide';
readonly chosen: string;
readonly rationale?: string;
} | {
readonly kind: 'iteration';
readonly index: number;
readonly target: string;
} | {
readonly kind: 'iteration-exit';
readonly index: number;
readonly reason?: string;
} | {
readonly kind: 'fork';
readonly parentSubflowId: string;
} | {
readonly kind: 'merge';
readonly mergedCount: number;
} | {
readonly kind: 'react';
readonly actorArrow: 'user→llm' | 'tool→llm' | 'llm→tool' | 'llm→user';
};
export interface RunStepRecorderOptions {
readonly id?: string;
}
/** Factory — matches the `boundaryRecorder()` / `topologyRecorder()` style. */
export declare function runStepRecorder(options?: RunStepRecorderOptions): RunStepRecorder;
/**
* 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 declare class RunStepRecorder implements CombinedRecorder {
readonly id: string;
/** Composition: storage shelf for the slider-step sequence. */
private readonly store;
/** 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. */
private readonly runIdGuard;
/** Stack of currently-open boundaries. The recorder owns this
* directly because it's a simple stack and frames are recorder-
* shaped. */
private boundaryStack;
/** Fork-emission coalescing + branch-exit tally. */
private readonly forks;
/** Tracks the most-recent leaf exit per depth → "forwards" handoff. */
private readonly siblings;
/** Buffers a "this MIGHT be the answer" leaf until onRunEnd. */
private readonly answerBuffer;
/** Run-root inference state machine (leaf vs composition). */
private readonly rootInferrer;
/** llm.start / llm.end actor-arrow classifier. */
private readonly actorArrows;
/** Has the first `asks` step fired? */
private asksEmitted;
constructor(options?: RunStepRecorderOptions);
/**
* 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.
*/
private push;
/** Internal seq-numbering helper — mirrors the store size so each
* RunStep gets a unique 0-based index in emit order. */
private get entryCount();
clear(): void;
/** 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. */
private resetForNewRun;
private observeRunId;
onRunStart(event: FlowRunEvent): void;
onRunEnd(event: FlowRunEvent): void;
onSubflowEntry(event: FlowSubflowEvent): void;
onSubflowExit(event: FlowSubflowEvent): void;
onFork(event: FlowForkEvent): void;
onDecision(event: FlowDecisionEvent): void;
onLoop(event: FlowLoopEvent): void;
/**
* 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: EventDispatcher): Unsubscribe;
/** 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). */
protected ingestTypedEvent(event: AgentfootprintEvent): void;
/**
* 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: readonly DomainEvent[]): void;
/**
* 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: DomainEvent): void;
/**
* 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?: readonly string[]): readonly RunStep[];
/** 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). */
private flushCandidateAnswer;
}
export {};