agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
395 lines (394 loc) • 22.9 kB
TypeScript
/**
* conventions — subflow + stage ID constants (builder↔recorder protocol).
*
* Pattern: Single Source of Truth constants (Ward Cunningham's SSOT).
* Role: Contract between `core/` builders and `recorders/core/` observers.
* Builders mount subflows with these IDs; recorders pattern-match
* on the IDs to emit grouped domain events.
* Emits: N/A (constants only).
*
* Rename any ID here → both builders and recorders stay in sync.
*/
import type { ContextSlot } from '../events/types.js';
/** Subflow IDs — mounted by builders, observed by recorders. */
export declare const SUBFLOW_IDS: {
/** Injection Engine subflow. Evaluates every Injection's trigger
* and writes activeInjections[] for the slot subflows to consume. */
readonly INJECTION_ENGINE: "sf-injection-engine";
/** Inner subflow inside LLMCall that wraps the invocation
* (seed + slots + call-llm + optional thinking + extract-final).
* Mounted by LLMCall's outer `client` chart. */
readonly LLM_CALL: "sf-llm-call";
/** System-prompt slot subflow. Observed by ContextRecorder. */
readonly SYSTEM_PROMPT: "sf-system-prompt";
/** Messages slot subflow. */
readonly MESSAGES: "sf-messages";
/** Tools slot subflow. */
readonly TOOLS: "sf-tools";
/** ReAct router subflow (inside Agent). */
readonly ROUTE: "sf-route";
/** Tool-call execution subflow (inside Agent loop). */
readonly TOOL_CALLS: "sf-tool-calls";
/** Merge step inside Parallel. */
readonly MERGE: "sf-merge";
/** Final-answer composition inside Agent. Mounted via
* `addSubFlowChartBranch('final', ...)` so the subflow id is the
* Route decider's branch key — `'final'`, no `sf-` prefix. The
* decider returns `'final'` as a routing value AND the same string
* becomes the subflow's id. */
readonly FINAL: "final";
/** Cache subflow (v2.14). Wraps the whole per-turn cache machinery —
* decide markers → CacheGate decider → apply/skip — as ONE collapsible
* boundary in the chart. Provider-independent decision layer; the
* attached provider's CacheStrategy turns markers into wire format.
* UpdateSkillHistory stays OUTSIDE (in the main loop) so the rolling
* skillHistory window persists across iterations without round-tripping
* through this subflow. */
readonly CACHE: "sf-cache";
/** Cache decision subflow (v2.6). Walks activeInjections, emits
* agnostic CacheMarker[]. Provider-independent. Standalone building
* block; the agent now uses the `decideCacheMarkers` stage inside
* `sf-cache` instead of mounting this directly. */
readonly CACHE_DECISION: "sf-cache-decision";
/** Thinking-normalization mount (v2.14). Wraps the consumer's
* ThinkingHandler.normalize() in a real footprintjs subflow so it
* has its own runtimeStageId for tracing. The result lands on the
* parent LLMCall's `thinkingBlocks` payload, so this subflow is
* pure plumbing from the agent step's POV — never a user-facing
* step in the StepGraph. */
readonly THINKING: "sf-thinking";
/** The inputs layer (honesty layer 2) — mounted between the LLM call and
* Route ONLY when armed (a registered tool declares `askOrAssume`, or the
* agent was built with `.inputsLayer()`). Runs once per batch: Declare →
* Verify → Record → Resolve, filing one `argument` row per ruled argument
* per call and handing ToolCalls the values the library fills. Absent on
* every other agent — the chart is byte-identical. */
readonly INPUTS: "sf-inputs";
/** The results layer (honesty layer 3, step 7b) — mounted at the LOOP HEAD
* (before `Compact`, which it precedes as the loop target) ONLY when armed
* (a registered tool declares a `ToolPeriod`, or the agent was built with
* `.resultsLayer()`). Reads the batch ToolCalls just ran — Declare → Verify
* → Record → Resolve — and files one `period` row per call that declared a
* period or whose tool declares a `ToolPeriod`. Absent on every other agent
* — the chart is byte-identical. */
readonly RESULTS: "sf-results";
};
export type SubflowId = (typeof SUBFLOW_IDS)[keyof typeof SUBFLOW_IDS];
/**
* The subflow-id prefix the framework reserves for its OWN composition
* segments. Every id in {@link SUBFLOW_IDS} carries it except
* {@link SUBFLOW_IDS.FINAL} (a decider BRANCH KEY that doubles as a mount id,
* not a name we chose), and the set is deliberately open-ended — builders
* generate more of them per feature and per release (`sf-router-llm`,
* `sf-memory-read-<id>`, `sf-memory-write-<id>`, …).
*
* Reserved rather than merely conventional because every reader downstream
* tells LIBRARY PLUMBING from CONSUMER STRUCTURE by this prefix and nothing
* else:
*
* • commentary skips `sf-*` segments while walking `meta.subflowPath` back
* to a user-facing agent name (`commentary/commentaryTemplates.ts`);
* • `BoundaryRecorder` hides them from the StepGraph so they never show up
* as steps a person has to scrub past;
* • the OTel bridge drops a slot-fork selection whose members are all
* `sf-*` (`adapters/observability/otel.ts`);
* • {@link stageRole} and the pattern/fingerprint readers classify by the
* same convention.
*
* So a consumer branch named `sf-billing` does NOT fail like a name clash —
* it is silently read as framework plumbing and vanishes from the very views
* it was built to appear in. That is two different facts (framework segment /
* consumer segment) sharing one namespace with no law, which is why the doors
* that accept a consumer-chosen segment name refuse this prefix outright.
*/
export declare const RESERVED_SUBFLOW_PREFIX = "sf-";
/** True when a subflow-path segment falls inside the reserved namespace. */
export declare function isReservedSubflowSegment(segment: string): boolean;
/**
* Refuse a consumer-chosen name that would become a reserved subflow-path
* segment — at DECLARATION time, where the name was typed, rather than at run
* time where the damage is invisible.
*
* Refuse-by-domain: a name that cannot be declared cannot collide. There is no
* escape hatch and no rename-behind-the-scenes, because a silently rewritten
* id would break the one thing the consumer wanted (finding their own segment
* in the trace) in a second, quieter way.
*
* @param door the API that accepted the name, e.g. `Parallel.branch()`
* @param noun what the name IS at that door, e.g. `branch id`
* @param id the consumer-supplied name
*/
export declare function assertUnreservedSubflowSegment(door: string, noun: string, id: string): void;
/** Stage IDs — plain function stages that builders mount. */
export declare const STAGE_IDS: {
readonly SEED: "seed";
/** Relevance entry router (`entryByRelevance`). A once-per-turn function stage
* mounted between Initialize and InjectionEngine (off the ReAct loop) that
* picks the starting skill by embedding similarity → sets `currentSkillId`. */
readonly PICK_ENTRY: "pick-entry";
/** Context-assembly selector stage. Runs AFTER InjectionEngine and
* fans the 3 slot subflows (system-prompt / messages / tools) out in
* PARALLEL (selector picks all 3 every iteration; failFast so a
* required slot's throw aborts the turn). They converge before
* CacheDecision. Shared by buildAgentChart + buildDynamicAgentChart;
* the flat viz proof chart uses the same id as its root selector. */
readonly CONTEXT: "context";
/** Outer "client" stage in LLMCall's wrapped chart. Receives args on
* the first visit, $break()s on the second (post-loop) visit with
* the LLM answer as TraversalResult. This is the lens-friendly
* affordance — the User pill maps to this stage. */
readonly CLIENT: "client";
readonly CALL_LLM: "call-llm";
/** Final-response extraction stage that runs after CallLLM (and
* optional sf-thinking). For LLMCall this is mostly symmetric with
* Agent's `sf-final` branch — gives lens a "Final" node and a
* clear commit boundary marking "we have the answer." */
readonly EXTRACT_FINAL: "extract-final";
readonly FINAL: "final";
/** The Route decider's third branch (7.26), mounted ONLY on an agent built
* with `.outputSchema(parser, { retries })`. It writes the corrective turn
* and loops back — a re-ask is an ordinary iteration, not a mode. */
readonly OUTPUT_RETRY: "output-retry";
/** The Route decider's unfinished-steps branch (9.18.0), mounted ONLY on an
* agent with ≥1 stepped skill. When a would-be-final answer leaves declared
* steps unrun, ONE teaching nudge goes back and the loop turns once more —
* the SchemaRetry mechanism verbatim (an ordinary iteration, not a mode).
* At most once per turn; never a forced continue. */
readonly STEP_NUDGE: "step-nudge";
/** The Route decider's evidence branch (9.35.0), mounted ONLY on an agent
* built with `.namesAndNumbersFromEvidence({ posture: 'guard' | 'rails' })`.
* When a would-be-final answer states names or numbers that appear in no
* tool result, they are named back to the model and the loop turns once
* more — the SchemaRetry mechanism verbatim (an ordinary iteration, not a
* mode). At most once per turn. */
readonly EVIDENCE_RECHECK: "evidence-recheck";
readonly NEGATIVE_RECHECK: "negative-recheck";
/** The Route decider's out-of-budget branch (9.56.0), mounted on any agent
* that can call tools unless `wrapUpAtMaxIterations: false` turned it off.
* When `maxIterations` runs out while the model is still asking for tools,
* it appends one instruction and the loop turns ONCE more with the tools
* withheld — the SchemaRetry mechanism verbatim (an ordinary iteration, not
* a mode). At most once per turn, and that last call cannot loop: with no
* tools on the wire there is nothing for the model to ask for. */
readonly WRAP_UP: "wrap-up";
/** The final branch's first stage: the turn's answer is captured — and, with `.answerValidation()`, checked — here (9.98.1: a milestone, so a scrub can stop on the answer). */
readonly PREPARE_FINAL: "prepare-final";
readonly FORMAT_MERGE: "format-merge";
readonly MERGE_LLM: "merge-llm";
readonly EXTRACT_MERGE: "extract-merge";
/** Window-strategy stage (7.16 as compaction; any strategy since 7.17).
* Mounted ONLY when `.window()` / `.compaction()` is configured, and when
* it is, it BECOMES the ReAct loop target — so it runs once per iteration
* boundary and everything downstream in the turn (injection triggers, all
* 3 slots, the wire) sees one window. Without a strategy the stage does
* not exist and the loop target is unchanged. The id stays `compact` for
* every strategy: it is the protocol name every lens and matcher already
* binds to, and which strategy ran is on the record. */
readonly COMPACT: "compact";
/** Messages-slot delivery stage (7.21). Mounted ONLY when the agent has
* something that could target the messages slot (a registered injection
* that declares `inject.messages`, or any `.memory()` whose recall might).
* Runs right after the InjectionEngine and before the slots, so the
* delivered message is part of the window every downstream reader sees.
* Absent otherwise — an agent with nothing to deliver runs the chart it
* always ran. */
readonly DELIVER: "deliver";
/** Updates the rolling skill-history window before CacheGate
* evaluates skill-churn (v2.6). */
readonly UPDATE_SKILL_HISTORY: "update-skill-history";
/** CacheGate decider stage — routes to apply-markers / no-markers
* based on kill switch / hit rate / skill churn (v2.6). */
readonly CACHE_GATE: "cache-gate";
/** CacheGate branch (routing key) when markers SHOULD be applied
* this iteration. Pass-through stage; markers stay in scope. (v2.6) */
readonly APPLY_MARKERS: "apply-markers";
/** CacheGate branch (routing key) when markers should be SKIPPED
* this iteration. Stage clears scope.cacheMarkers. (v2.6) */
readonly SKIP_CACHING: "no-markers";
/** BuildLLMRequest stage — calls strategy.prepareRequest to apply
* markers to the wire request (v2.6). */
readonly BUILD_LLM_REQUEST: "build-llm-request";
/** The inputs layer's four stages, inside `sf-inputs` (honesty layer 2):
* which calls carry rules and which ruled values are missing … */
readonly DECLARE_ARGUMENTS: "declare-arguments";
/** … where each ruled value came from … */
readonly VERIFY_ARGUMENTS: "verify-arguments";
/** … one `argument` row per ruled argument, and its event … */
readonly RECORD_ARGUMENTS: "record-arguments";
/** … and the fills and refusals ToolCalls applies. */
readonly RESOLVE_ARGUMENTS: "resolve-arguments";
/** The results layer's four stages, inside `sf-results` (honesty layer 3):
* which calls of the batch just run declared a period, or come from a tool
* that declares a `ToolPeriod` … */
readonly DECLARE_RESULTS: "declare-results";
/** … the verdict on each one's period … */
readonly VERIFY_RESULTS: "verify-results";
/** … one `period` row per call, and its event … */
readonly RECORD_RESULTS: "record-results";
/** … and what the layer does about them: flag (the rows are the flags). */
readonly RESOLVE_RESULTS: "resolve-results";
/** The answer layer (honesty layer 4) — mounted as the FIRST stage of the
* final branch ONLY when armed (`.answerLayer()`): the answer's standing,
* folded from the committed record, filed as data for PrepareFinal and
* announced once (`agentfootprint.answer.assessed`). Absent on every other
* agent — the final branch starts at PrepareFinal, byte-identical. */
readonly ASSESS_ANSWER: "assess-answer";
};
export type StageId = (typeof STAGE_IDS)[keyof typeof STAGE_IDS];
/** True when a subflow id corresponds to one of the 3 context slots. */
export declare function isSlotSubflow(id: string): id is typeof SUBFLOW_IDS.SYSTEM_PROMPT | typeof SUBFLOW_IDS.MESSAGES | typeof SUBFLOW_IDS.TOOLS;
/** Map a slot subflow id to its ContextSlot type. Undefined for non-slot ids. */
export declare function slotFromSubflowId(id: string): ContextSlot | undefined;
/**
* Resolve the context slot a scope write belongs to FROM THE WRITE'S OWN
* `runtimeStageId` — not from a "currently-open slot" stack.
*
* Why: once the 3 slot subflows run in PARALLEL (selector fan-out), their
* entry/write/exit events INTERLEAVE — a stack top is unreliable, so a write
* inside `sf-messages` could be attributed to (or dropped against)
* `sf-tools`. The write's `runtimeStageId` (`[subflowPath/]stageId#index`)
* always encodes which slot subflow enclosed it; we scan the path segments
* innermost-first for a slot id. Matches the sequential result exactly
* (the write is still inside its own slot), so it is behavior-preserving.
*/
export declare function slotFromRuntimeStageId(runtimeStageId: string): ContextSlot | undefined;
/** True when an id is any of the library's known subflow IDs. */
export declare function isKnownSubflow(id: string): id is SubflowId;
/** True when an id is any of the library's known stage IDs. */
export declare function isKnownStage(id: string): id is StageId;
/**
* Semantic role of a stage, used by renderers to decide visual emphasis.
*
* The agent's chart mixes a handful of stages users actually care about
* (the HEROES — what context was built, what the model decided, what it did)
* with mechanism stages (PLUMBING). This is the ONE place that says which is
* which; renderers stay generic and style purely off this role (e.g. heroes
* prominent, plumbing muted). Keeping it here — the semantic owner — avoids
* the "name-based filter list duplicated across renderers" anti-pattern.
*
* - `hero-slot` — a context slot (system-prompt / messages / tools)
* - `hero-llm` — the LLM invocation
* - `hero-action` — tool execution (the agent's actions)
* - `plumbing` — mechanism (injection engine, cache, route, thinking, …)
* - `boundary` — neutral chart boundaries (Initialize root, Final) +
* anything unrecognised (rendered normally, never muted)
*/
export type StageRole = 'hero-slot' | 'hero-llm' | 'hero-action' | 'plumbing' | 'boundary';
/**
* Classify a stage id into its {@link StageRole}. Accepts a path-qualified id
* (`sf-llm-call/call-llm`) — only the LOCAL segment matters, so it works at
* any nesting depth. Built entirely from the id constants above, so adding a
* stage to the chart only requires listing it here.
*/
export declare function stageRole(id: string): StageRole;
/**
* A {@link Milestone}'s kind — the domain vocabulary for "meaningful step you'd
* scrub to" in the agent's run.
*/
export type MilestoneKind = 'iteration' | 'slot' | 'llm-turn' | 'tool-call' | 'decision';
/**
* A time-travel milestone: a domain-declared scrub stop. Conceptually each
* milestone marks the boundary of a COLLECTION of commits (the commits that
* belong to that step) — so the Lens slider can step stage-by-stage
* (iteration → llm-turn → tool-call → …) instead of stopping only on
* structural subflow boundaries. The renderer iterates whatever the domain
* classifies; it never hardcodes agent vocabulary.
*/
export interface Milestone {
readonly kind: MilestoneKind;
/** Human-readable base label ("LLM turn"); the renderer may add an ordinal. */
readonly label: string;
}
/** Every {@link MilestoneKind}, for validating a kind read off a recording. */
export declare const MILESTONE_KINDS: readonly MilestoneKind[];
/**
* Classify a stage id into a {@link Milestone}, or `null` when the stage is NOT
* a milestone boundary (its commits fold into the surrounding milestone's
* collection). This is the DOMAIN's declaration of which steps are scrub-worthy;
* the Lens consumes it to build the time-travel slider (see
* agentfootprint-lens `cursorPositionsAtDrill`).
*
* **Since 9.90.0 this is the FALLBACK, not the fact.** The charts declare the
* same milestones as tags at build time ({@link milestoneTagsFor}), footprintjs
* 9.21 stamps them on the stage's first commit bundle (`CommitBundle.tags`), and
* `milestoneStops` reads the bundle first — it derives from the id only for a
* bundle that carries no tags (a recording made before 9.90.0). Every milestone
* stage is declared, slot branch mounts included (footprintjs 9.21.1). Same
* table either way, so the two readings agree.
*
* Mirrors {@link stageRole}: accepts a runtimeStageId (`call-llm#17`), a
* path-qualified id (`sf-llm-call/call-llm`), or a bare local id — only the
* LOCAL stage segment matters, so it works at any nesting depth and for both
* commit ids and subflow-group ids.
*/
export declare function milestoneFor(id: string): Milestone | null;
/** Prefix of the tag that names a milestone's KIND: `milestone:llm-turn`. */
export declare const MILESTONE_TAG_PREFIX = "milestone:";
/** Prefix of the tag that names a milestone's LABEL: `milestone-label:LLM turn`. */
export declare const MILESTONE_LABEL_TAG_PREFIX = "milestone-label:";
/** The kind tag for a {@link MilestoneKind} — `'milestone:' + kind`. */
export declare function milestoneTag(kind: MilestoneKind): string;
/**
* The tags that DECLARE a {@link Milestone} on a stage: its kind tag and its
* label tag, in that order. What a declaration site spreads into `.tag(...)`
* or `{ tags }`; what {@link milestoneFromTags} reads back.
*/
export declare function milestoneTags(milestone: Milestone): readonly string[];
/**
* The tags to declare on the stage with this LOCAL id, from the same table
* {@link milestoneFor} reads — so a chart-building site names the id it is
* mounting and never spells a kind or a label. Throws at build time for an id
* the table does not classify: a declaration site that is not a milestone is
* a wiring mistake, and a silent no-op would be a forgotten tag by another name.
*
* @example
* ```ts
* builder
* .addFunction('CallLLM', callLLM, STAGE_IDS.CALL_LLM, 'LLM invocation')
* .tag(...milestoneTagsFor(STAGE_IDS.CALL_LLM)); // 'milestone:llm-turn', 'milestone-label:LLM turn'
* ```
*/
export declare function milestoneTagsFor(localStageId: string): readonly string[];
/**
* Read a {@link Milestone} back off a commit bundle's declared `tags`, or
* `null` when they carry no milestone kind tag. The kind must be one of
* {@link MILESTONE_KINDS}; the label is the `milestone-label:` tag when one was
* declared, else `labelWhenUndeclared` (a reader passes the stop's own label —
* the chart's word for the stage), else the kind itself. A stored row is
* `unknown[]`-shaped until narrowed: non-strings are ignored, never a milestone.
*
* @example
* ```ts
* milestoneFromTags(['milestone:llm-turn', 'milestone-label:LLM turn']);
* // { kind: 'llm-turn', label: 'LLM turn' }
* milestoneFromTags(['audit']); // null — tagged, but not a milestone
* milestoneFromTags(undefined); // null — an untagged bundle
* ```
*/
export declare function milestoneFromTags(tags: readonly unknown[] | undefined, labelWhenUndeclared?: string): Milestone | null;
/**
* Scope-key convention for context injections.
*
* Each slot subflow writes its injections to a well-known scope key.
* ContextRecorder observes writes to these keys to emit context.injected
* events. Builders that mount slot subflows MUST write injections to the
* corresponding key; this is the data-level contract between builder and
* recorder.
*/
export declare const INJECTION_KEYS: {
readonly SYSTEM_PROMPT: "systemPromptInjections";
readonly MESSAGES: "messagesInjections";
readonly TOOLS: "toolsInjections";
};
export type InjectionKey = (typeof INJECTION_KEYS)[keyof typeof INJECTION_KEYS];
/** Map a slot to its injection scope key. */
export declare function injectionKeyForSlot(slot: 'system-prompt' | 'messages' | 'tools'): InjectionKey;
/** True when a scope key is any of the known injection keys. */
export declare function isInjectionKey(key: string): key is InjectionKey;
/**
* The scope key the receipt is committed under. Written by `callLLM` and the
* message-API charts, read back by `time-travel/servedView.ts` and the replay
* hooks — a state-key name both sides agree on, so it lives here rather than
* beside the receipt's builder (`core/agent/receipt/receipt.ts`).
*/
export declare const RECEIPT_KEY = "receipt";