agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
401 lines (400 loc) • 22.1 kB
TypeScript
/**
* buildAgentChart — assemble the agent's full footprintjs FlowChart
* from stage functions + slot subflows + memory wiring.
*
* This is the "chart composition" that used to live inline in
* `Agent.buildChart()`. Extracted for v2.11.2 so:
*
* 1. Agent.ts focuses on Agent class lifecycle (constructor, run,
* attach, getSpec) instead of chart wiring details.
* 2. The reliability gate chart (v2.11.x) wires into ONE focused
* file rather than surgically into Agent.ts's 250-line composition
* block.
* 3. The composition is independently readable + reviewable —
* consumers building custom agent shapes have a reference.
*
* Chart shape:
*
* Initialize
* → [memory READ subflows for each .memory()]
* → InjectionEngine (subflow) ← loop target (tool-calls loops here)
* → Context (selector, PARALLEL fan-out, failFast)
* ⇉ {System Prompt ‖ Messages ‖ Tools} (slot subflows)
* → converge
* → UpdateSkillHistory
* → Cache (sf-cache subflow: decideCacheMarkers → CacheGate
* → ApplyMarkers / SkipCaching)
* → CallLLM (also emits the per-iteration iteration_start marker)
* → [NormalizeThinking] (subflow, only when a ThinkingHandler resolved)
* → Route (decider)
* ├─ tool-calls (pausable) → loopTo(InjectionEngine) ← branch-sourced loop
* └─ final (subflow) → terminal leaf
* ┌────── PrepareFinal
* ├──── [memory WRITE subflows]
* └──── BreakFinal ($break)
*
* (This chart has no reliability subflow, and never grew one. The plan
* described here — "the reliability gate chart mounts as a subflow before
* CallLLM with a TranslateFailFast stage after it. Lands in the next
* commit." — did not land: `.reliability()` is implemented INLINE in the
* CallLLM stage by `executeWithReliability`, and
* `buildReliabilityGateChart` is reachable from no shipped path. Verified
* 2026-07-28.)
*/
import type { FlowChart, StructureRecorder } from 'footprintjs';
import type { CachePolicy } from '../../cache/types.js';
import type { MemoryDefinition } from '../../memory/define.types.js';
import { readerWindowsOf } from '../../time/windows.js';
import { type TimeLimitFacts } from '../../semantics/coverage/timeLimitFacts.js';
import { type AnswerMountDeps, type InputsMountDeps, type ResultsMountDeps } from './honesty/mounts.js';
import type { RouteBranch } from './stages/route.js';
/**
* Stage handlers + slot subflows the chart composer needs. Mostly
* passed through verbatim from Agent.buildChart() — the chart shape
* is identical to what was inline before.
*/
export interface AgentChartDeps {
/** Memory READ/WRITE pipeline definitions (one per `.memory()`). */
readonly memories: readonly MemoryDefinition[];
/** Evidence bridge (#5): `causalEvidenceRecorder().collect`, threaded into
* CAUSAL memories' write mounts so snapshots persist real evidence
* (decisions/toolCalls/iterations/duration/tokens) instead of zeros.
* Set by the Agent when any mounted memory is CAUSAL. */
readonly causalEvidenceSource?: () => import('../../memory/causal/evidenceRecorder.js').RunEvidence;
/** Cache policy for the system-prompt slot, threaded into
* CacheDecision's inputMapper so its decision rules can match. */
readonly systemPromptCachePolicy: CachePolicy;
/** Hard ReAct iteration cap, threaded into CacheDecision's
* inputMapper for max-iteration policies. */
readonly maxIterations: number;
readonly seed: (scope: never) => void | Promise<void>;
readonly callLLM: (scope: never) => Promise<void>;
readonly routeDecider: (scope: never) => RouteBranch | Promise<RouteBranch>;
readonly toolCallsHandler: import('footprintjs').PausableHandler<never>;
/**
* The schema re-ask branch (7.26). Present ONLY when the agent was built
* with `.outputSchema(parser, { retries })` — and when present it is a
* THIRD branch of the Route decider carrying the same `{ loopTo }` the
* tool branch does, because a re-ask is one more ordinary turn.
*
* Conditional mount, for the reason every conditional mount here exists:
* an agent that did not opt in gets no branch, no stage, no scope key and
* no event — its chart and its commit log are the ones it always had.
*/
readonly outputRetryStage?: (scope: never) => void;
readonly injectionEngineSubflow: FlowChart;
/** Relevance entry router (`entryByRelevance`) — a once-per-turn function stage
* mounted before the InjectionEngine (off the ReAct loop). Present only when the
* skill graph was built with a relevance scorer. */
readonly pickEntryStage?: (scope: never) => Promise<void>;
/**
* The turn-start routing CASCADE stage (SG-C) — subsumes PickEntry on
* graphs that run it (`classify` configured, or `continuity:
* 'conversation'`). Mounted in the SAME slot under the SAME id
* (`STAGE_IDS.PICK_ENTRY`) so recorded structures stay stable; never
* present together with `pickEntryStage` (Agent.buildChart picks exactly
* one). Absent → the chart is byte-identical to 9.16.
*/
readonly routeTurnStage?: (scope: never) => Promise<void>;
/**
* The window-strategy stage (`.window()` / `.compaction()`). Present ONLY
* when the consumer configured a strategy — and when present it BECOMES the
* ReAct loop target, mounted immediately before the current one.
*
* It has to be the loop target rather than merely sit in the loop body:
* the loop is branch-sourced (`tool-calls → { loopTo }`), so anything ahead
* of the target runs once and is never seen again. Being the target also
* puts the window change BEFORE the injection engine and the slots, which
* is the point — the triggers, the three slots and the wire then all see
* the same window, and no component gets a different past than the model
* does.
*
* `strategyName` rides along so the chart itself says which policy is
* mounted; a reader of the graph should not have to guess.
*/
readonly windowStage?: {
readonly strategyName: string;
readonly run: (scope: never) => Promise<void>;
};
/**
* The messages-slot DELIVERY stage (7.21). Present ONLY when the agent has
* something that could target the messages slot — a registered injection
* declaring `inject.messages`, or any `.memory()` whose recall might format
* as a non-system role. When absent the chart is the one it always was, so
* an agent with nothing to deliver is byte-identical to 7.20.
*
* Mounted between the InjectionEngine and the Context fan-out: after the
* engine has decided what is active, before anything reads the window. The
* placement is the design — a delivered message has to be part of the past
* that the slots project, the cache decision indexes, and the wire sends,
* or the recording and the request would disagree about the conversation.
*/
readonly deliverStage?: (scope: never) => void;
readonly systemPromptSubflow: FlowChart;
readonly messagesSubflow: FlowChart;
readonly toolsSubflow: FlowChart;
/**
* Optional thinking-normalization sub-subflow (v2.14+). Mounted as a
* stage AFTER CallLLM, BEFORE Route, only when a `ThinkingHandler`
* resolved (declared by the provider or explicitly set
* via `.thinkingHandler()`). When undefined, the stage is NOT added —
* zero overhead for non-thinking agents (build-time conditional mount).
*/
readonly thinkingSubflow?: FlowChart;
readonly updateSkillHistoryStage: (scope: never) => void;
/**
* Whether ≥1 Skill is registered. The `UpdateSkillHistory` stage (and
* therefore the cache's skill-churn rule) is mounted ONLY when true:
* with no skills the window would record "no skill" every iteration and
* `detectSkillChurn` could never fire, so the stage would be pure dead
* weight + a misleading box. Mirrors the `skills.length > 0` gate that
* auto-attaches `read_skill`, and the `thinkingSubflow` conditional mount.
*/
readonly hasSkills: boolean;
/**
* Whether ≥1 registered skill declares `steps` (9.18.0). Gates the step
* pointer's mapper threading — engine in/out, the tools-slot arg, and (in
* the grouped chart) the `sf-llm-call` boundary — so an agent without
* stepped skills seeds and commits exactly the keys it always did. The
* threading lives HERE in the builders, not in the subflow (the SG-C
* blast-radius lesson): the alias discipline is
* `stepPointer` in (readonly input) → Evaluate writes `nextStepPointer`
* (the currentSkillId/nextSkillCursor precedent — the pointer changes
* every iteration, so the turn-constant `turnRoute` pattern would serve
* the tools slot a stale value) → mappers carry the alias back onto the
* parent's `stepPointer`.
*/
readonly hasSteps?: boolean;
/**
* The mount kernel's plan (9.58.0) — present ONLY on an agent built with
* `.maps()`. Gates the `mapEngagement`/`nextMapEngagement` alias round
* trip through the injection-engine boundary (the stepPointer discipline
* verbatim). Absent → the keys are never threaded, so every other agent's
* mapper bytes are exactly what they were.
*/
readonly engagementPlan?: import('../../maps/engagement/types.js').EngagementPlan;
/**
* The unfinished-steps nudge branch (9.18.0). Present ONLY on an agent
* with ≥1 stepped skill — and when present it is one more branch of the
* Route decider carrying the same `{ loopTo }` the tool branch does,
* because a nudge is one more ordinary turn (the SchemaRetry mechanism
* verbatim). Absent → no branch, no stage, no scope key, no event.
*/
readonly stepNudgeStage?: (scope: never) => void;
/**
* The evidence recheck branch (9.35.0). Present ONLY on an agent built with
* `.namesAndNumbersFromEvidence({ posture: 'guard' | 'rails' })` — the
* `'assist'` posture records and never loops, so it mounts no branch. One
* more branch of the Route decider carrying the same `{ loopTo }` the tool
* branch does, because a correction is one more ordinary turn (the
* SchemaRetry mechanism verbatim). Absent → no branch, no stage, no event.
*/
readonly evidenceRecheckStage?: (scope: never) => void;
readonly negativeRecheckStage?: (scope: never) => void;
/**
* The out-of-budget wrap-up branch (9.56.0). Present on any agent that can
* CALL a tool — a registered tool, or a `ToolProvider` that might list one —
* unless `wrapUpAtMaxIterations: false` turned it off. An agent with no tool
* surface can never run out of budget mid-action (a limit only cuts a turn
* short when tool calls were pending), so it mounts no branch and its chart
* is drawn exactly as it always was.
*
* One more branch of the Route decider carrying the same `{ loopTo }` the
* tool branch does, because a wrap-up is one more ordinary turn (the
* SchemaRetry mechanism verbatim). Absent → no branch, no stage, no scope
* key, no event.
*/
readonly wrapUpStage?: (scope: never) => void;
/**
* The evidence gate is mounted (9.35.0), at ANY posture. In the GROUPED
* chart this gates bubbling `systemPromptInjections` out of `sf-llm-call`:
* the slot writes it INSIDE the subflow and the outer Route decider needs
* it to exempt values the app's own prompt (base prompt, skill body, a
* retrieved passage) supplied — without the mapper key the gate would flag
* the prompt's own identifiers in the default grouped shape (the
* `hasEscalation` blast-radius lesson, one field down). The flat chart
* shares one scope and needs no key; the flag is still threaded there so
* both builders read the same deps object.
*/
readonly hasEvidenceGate?: boolean;
/**
* `.limitsTravelWithTheAnswer()` is configured on a PROSE answer (no output
* schema — a typed one sets `coverageLimitsAsData` instead). Swaps the
* final branch's first stage for the variant that folds the run's declared
* coverage into the answer before the turn payload is captured. Absent →
* the same stage function the chart has always mounted, byte for byte; the
* RECORDING half (events + `coverageDeclared`) is unconditional and does
* not depend on this flag.
*/
readonly attachCoverageLimits?: boolean;
/**
* `.limitsTravelWithTheAnswer()` on an agent with an output schema — the
* answer is TYPED, so its limits travel as DATA and the block is never
* appended (JSON followed by prose is not JSON). Swaps the final branch's
* first stage for the variant that captures the model's answer unchanged
* and projects the Route decider's committed `answerCoverage` onto
* `turn_end`. The Agent sets this OR `attachCoverageLimits`, never both;
* absent → the stage the chart has always mounted.
*/
readonly coverageLimitsAsData?: boolean;
/**
* `.scriptTravelsWithTheAnswer()` — the final stage carries this turn's
* recorded programs on `turn_end.scripts` and, on a prose answer (`typed`
* unset), appends them after the `---` separator (`stages/prepareFinal.ts` ·
* `prepareFinalFor`). Absent → the stage the chart has always mounted.
*/
readonly scriptTravel?: import('./scripts/types.js').ResolvedScriptTravel & {
readonly typed?: true;
};
/** Opt-in terminal guard and one committed token; absent preserves the original final stage. */
readonly hasAnswerValidation?: boolean;
/** Output middleware owns delivery: publish only the final captured answer. */
readonly releaseOutputTokens?: true;
/** The evidence gate's `'rails'` posture: a refused answer is recorded flagged and ends the branch (`prepareFinalFor`). */
readonly evidenceRails?: true;
/**
* A tool's `displayName` (the app's words for a call), read through the
* agent's own tools — present only where a tool can declare one. The prose
* limits block names an item's declaring calls in those words
* (`stages/prepareFinal.ts` · `prepareFinalFor`). Absent → the stage the
* chart has always mounted.
*/
readonly displayNameOf?: (toolName: string) => string | undefined;
/**
* The time layer (`.time()`) is armed: under `attachCoverageLimits`, the
* final stage renders each `Period:` line in the run's clock zone
* (`stages/prepareFinal.ts` · `prepareFinalFor`). Absent → the stage the
* chart has always mounted.
*/
readonly timeLayer?: true;
/**
* The time layer is armed (`.time()`). Gates ONE mount arg on the Tools
* branch's `inputMapper`: `timeWindows`, the turn's windows with the turn's
* clock (`time/windows.ts` · `readerWindowsOf`, off
* `parent.findingsLedger`) — the reader's (step T6b) and the app's control
* window (`time.window`) — value-conditional, so a turn with none crosses no
* key. The slot serves the reader's on each tool that declares a period and
* the control window on every request (`ToolsSlotConfig.timeWindows`, G14).
*/
readonly timeWindows?: true;
/**
* The time layer is armed with the inputs layer (`.time()` over a tool that
* declares a period, step T8). Gates ONE mount arg on the Tools branch's
* `inputMapper`: `timeLimits`, the turn's result-check facts for the model
* (`semantics/coverage/timeLimitFacts.ts` · `timeLimitFactsOf`, audience `model`, off
* `parent.findingsLedger`) — value-conditional, so a turn whose reads match
* what was asked crosses no key. The slot renders and serves them in the
* one time line (`ToolsSlotConfig.timeLimits`); the renderer loads there,
* never on a plain agent's graph.
*/
readonly timeLimits?: true;
/**
* An escalation brain is declared (9.19.0). In the GROUPED chart this
* gates threading `skillEscalated` across the `sf-llm-call` boundary —
* the flip is written by tool-calls on the OUTER scope and read by
* callLLM INSIDE the subflow, so without the mapper key the escalation
* would silently never serve a call in the default grouped shape (the
* SG-C blast-radius lesson: the threading lives in the builders). The
* flat chart shares one scope and needs no key; the flag is still
* accepted here so both builders take identical deps.
*/
readonly hasEscalation?: boolean;
/** Findings is armed. The tools slot plants a stable optional schema;
* live call assembly serves the current offer and ledger in late DATA.
* This flag never makes the cache subflow suppress findings cache markers.
*/
readonly hasFindingsLedger?: boolean;
/**
* The inputs layer is armed (honesty layer 2) — present ONLY when a tool
* the build can see declares `askOrAssume`, or the agent was built with
* `.inputsLayer()`. Mounts `sf-inputs` between the LLM call and Route
* (`honesty/mounts.ts` · `mountInputsLayer`, the ONE helper both builders
* call) and swaps the final branch's limits stage for the variant that also
* carries the assumed values, when `.limitsTravelWithTheAnswer()` is on.
* Absent — the default — and the chart is byte-identical.
*/
readonly inputsLayer?: InputsMountDeps;
/**
* The results layer is armed (honesty layer 3, step 7b) — present ONLY when a
* tool the build can see declares a `ToolPeriod`, or the agent was built
* with `.resultsLayer()`. Mounts `sf-results` at the loop head, before the
* window strategy, and makes it the loop target (`honesty/mounts.ts` ·
* `mountResultsLayer`, the ONE helper both builders call). Absent — the
* default — and the chart is byte-identical.
*/
readonly resultsLayer?: ResultsMountDeps;
/**
* The answer layer is armed (honesty layer 4, `.answerLayer()`). Starts the
* final branch with the layer's stage — the answer's standing, folded from
* the committed record, filed for PrepareFinal and announced once
* (`honesty/mounts.ts` · `startFinalBranch`, the ONE helper both builders
* call) — and swaps PrepareFinal's body for the variant that carries the
* standing on `turn_end` (and, under `standingLine`, appends its line to a
* prose answer). Absent — the default — and the branch is byte-identical.
*/
readonly answerLayer?: AnswerMountDeps;
/**
* Tool choice by classifier is armed (`.toolChoice()`, 9.105.0). Gates
* THREE mount args on the Tools branch's `inputMapper` — `userMessage`
* (what the classifier reads), `priorToolChoices` (the parent's rows,
* under an alias because a mount input is frozen inside the subflow and
* the slot writes the fresh list under the base key) and `wrapUpAsked`
* (the wrap-up skip) — and ONE line on its `outputMapper`, carrying
* `toolChoices` back onto the parent key `callLLM` appends the outcome
* row to. In the grouped chart the same key crosses the `sf-llm-call`
* boundary both ways. Absent — the default — the mount maps exactly the
* keys it always did.
*/
readonly hasToolChoice?: boolean;
/**
* The declared ontology is armed (`.ontology()`, 9.106.0). The flat chart
* needs nothing from it — `callLLM` reads the run constant `ontology` off
* the root scope; in the grouped chart `callLLM` lives INSIDE `sf-llm-call`
* and the constant is written by seed on the OUTER scope, so the boundary's
* `inputMapper` carries it across, under this gate and value-conditionally.
* Absent — the default — the boundary maps exactly the keys it always did.
*/
readonly hasOntology?: boolean;
/**
* ReAct loop semantics. `'dynamic'` (default) re-runs the InjectionEngine +
* all 3 slots every iteration (loop → InjectionEngine). `'classic'`
* engineers context ONCE (InjectionEngine + system-prompt + tools up front)
* and loops only the Messages slot (loop → Messages). See AgentOptions.reactMode.
*/
readonly reactMode?: 'classic' | 'dynamic';
/** Structure recorders threaded into both `flowChart()` calls (the
* main chart and the PrepareFinal sub-chart). Each recorder
* observes per-node build events (`onStageAdded` /
* `onSubflowMounted` / etc.) for the Agent's chart. Undefined when
* the consumer didn't attach any. */
readonly structureRecorders?: readonly StructureRecorder[];
}
/**
* Build the agent's complete FlowChart from the supplied deps.
*/
/** The Tools mount's `timeWindows` arg — the turn's windows (the reader's, the control's), or no key at all. */
export declare function timeWindowsArg(ledger: unknown): {
timeWindows?: ReturnType<typeof readerWindowsOf>;
};
/**
* The Tools mount's `timeClock` arg — the turn's run clock for the served
* line's first sentence (G16, `agent/arguments/serve.ts` · `clockSentence`),
* or no key when the turn has no clock. Under an unknown zone (G15) the zone
* is the UTC spelling, marked `zoneUnknown`.
*/
export declare function timeClockArg(ledger: unknown): {
timeClock?: {
now: string;
zone: string;
zoneUnknown?: true;
};
};
/**
* The Tools mount's `timeLimits` arg — the turn's result-check FACTS for the
* model (unrendered: the slot's served line renders them, under the arm), or
* no key.
*/
export declare function timeLimitsArg(ledger: unknown): {
timeLimits?: TimeLimitFacts;
};
export declare function buildAgentChart(deps: AgentChartDeps): FlowChart;