agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
674 lines • 41.9 kB
JavaScript
/**
* 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 { ArrayMergeMode } from 'footprintjs/advanced';
import { flowChart, select } from 'footprintjs';
import { STAGE_IDS, SUBFLOW_IDS, milestoneTagsFor } from '../../lib/conventions.js';
import { EMPTY_ACTIVE_BY_SLOT, } from '../../injection-engine/buildInjectionEngineSubflow.js';
import { memoryInjectionKey, retrievalEvidenceKey } from '../../memory/define.types.js';
import { unwrapMemoryFlowChart } from '../../memory/define.js';
import { mountMemoryRead, mountMemoryWrite } from '../../memory/wire/mountMemoryPipeline.js';
import { withMemoryRecall } from './memoryRecallInjections.js';
import { readerWindowsOf } from '../../time/windows.js';
import { clockOf } from '../../time/rows.js';
import { timeLimitFactsOf } from '../../semantics/coverage/timeLimitFacts.js';
import { breakFinalStage, breakFinalWithValidationStage, finalBranchOutput, } from './stages/breakFinal.js';
import { prepareFinalFor } from './stages/prepareFinal.js';
import { buildCacheSubflow } from './buildCacheSubflow.js';
import { mountInputsLayer, mountResultsLayer, RESULTS_LOOP_TARGET, startFinalBranch, } from './honesty/mounts.js';
import { memberRunArg } from '../memberRun.js';
/**
* 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 function timeWindowsArg(ledger) {
const windows = readerWindowsOf(ledger);
return windows === undefined ? {} : { timeWindows: windows };
}
/**
* 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 function timeClockArg(ledger) {
const clock = clockOf(ledger);
if (clock === undefined)
return {};
return {
timeClock: {
now: clock.now,
zone: clock.zone,
...(clock.zoneSource === 'unknown' && { 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 function timeLimitsArg(ledger) {
const rows = ledger;
const facts = timeLimitFactsOf(rows, clockOf(rows)?.turn, 'model');
return facts === undefined ? {} : { timeLimits: facts };
}
export function buildAgentChart(deps) {
// ReAct loop semantics. 'classic' caches the static slots (engineer
// system-prompt + tools only on the first turn); 'dynamic' (default)
// re-engineers all 3 slots every turn. Drives the Context selector below.
const reactMode = deps.reactMode ?? 'dynamic';
// Memory ids whose recall must be bridged into the slot composers (see
// memoryRecallInjections). Empty → withMemoryRecall is a no-op.
// Carries the flavor too (8.8.0): a corpus retrieval composes as
// `source: 'rag'`, conversation recall as `source: 'memory'`.
const memoryIds = deps.memories.map((m) => ({
id: m.id,
...(m.flavor !== undefined && { flavor: m.flavor }),
}));
// ── Final-branch subflow ─────────────────────────────────────
// Split so memory-write subflows can mount BETWEEN setting
// finalContent and breaking the ReAct loop. PrepareFinal captures
// the turn payload; BreakFinal terminates the loop.
// Same stage id, same position for PrepareFinal — only the body differs, and
// only for an agent that asked for its limits (or its standing) to travel.
// See `stages/prepareFinal.ts` · `prepareFinalFor`, the ONE choice both
// builders make, and `honesty/mounts.ts` · `startFinalBranch`, which puts
// the answer layer's stage first when it is armed and builds the branch
// exactly as it always was when it is not.
let finalBranchBuilder = startFinalBranch(deps.answerLayer, prepareFinalFor(deps), deps.structureRecorders);
for (const m of deps.memories) {
if (m.write) {
finalBranchBuilder = mountMemoryWrite(finalBranchBuilder, {
pipeline: {
read: unwrapMemoryFlowChart(m.read),
write: unwrapMemoryFlowChart(m.write),
},
identityKey: 'runIdentity',
turnNumberKey: 'turnNumber',
contextTokensKey: 'contextTokensRemaining',
newMessagesKey: 'newMessages',
writeSubflowId: `sf-memory-write-${m.id}`,
...(m.corpus !== undefined && { identityOverride: m.corpus }),
// Evidence bridge (#5): only CAUSAL pipelines consume run evidence.
...(m.type === 'causal' &&
deps.causalEvidenceSource && { evidenceSource: deps.causalEvidenceSource }),
});
}
}
const finalBranchChart = finalBranchBuilder
.addFunction('BreakFinal', deps.hasAnswerValidation === true ? breakFinalWithValidationStage : breakFinalStage, 'break-final', 'Terminate the ReAct loop')
.build();
// ── Main chart ──────────────────────────────────────────────
// Description prefix `Agent:` is a taxonomy marker — consumers
// (Lens + FlowchartRecorder) detect Agent-primitive subflows via
// this prefix and flag them as true agent boundaries (separate
// from LLMCall subflows which use `LLMCall:` prefix).
let builder = flowChart('Initialize', deps.seed, STAGE_IDS.SEED, {
...(deps.structureRecorders !== undefined && {
structureRecorders: [...deps.structureRecorders],
}),
// Tag the mode so the Lens can label the run. Keep the `Agent:` taxonomy
// prefix (consumers detect Agent boundaries by it). Dynamic keeps the
// historical 'Agent: ReAct loop' string for byte-stability.
description: reactMode === 'classic' ? 'Agent: Classic ReAct loop' : 'Agent: ReAct loop',
});
// Memory READ subflows — mounted between Initialize and InjectionEngine
// for TURN_START timing (default). Each memory writes to its own
// scope key (`memoryInjection_${id}`) so multiple `.memory()`
// registrations layer without colliding.
for (const m of deps.memories) {
builder = mountMemoryRead(builder, {
pipeline: {
read: unwrapMemoryFlowChart(m.read),
...(m.write !== undefined && { write: unwrapMemoryFlowChart(m.write) }),
},
identityKey: 'runIdentity',
turnNumberKey: 'turnNumber',
contextTokensKey: 'contextTokensRemaining',
injectionKey: memoryInjectionKey(m.id),
// The retrieval record, lifted into ROOT state so a backward slice
// can reach the passage instead of stopping at this mount.
evidenceKey: retrievalEvidenceKey(m.id),
readSubflowId: `sf-memory-read-${m.id}`,
// A corpus reads under its OWN namespace, not the run's identity.
...(m.corpus !== undefined && { identityOverride: m.corpus }),
});
}
// The turn-start slot — ONE stage, two possible occupants, same id
// (recorded structures stay stable). RouteTurn is the SG-C cascade
// (rules → classifier/scorer → menu, continuity honored); PickEntry is the
// 9.x relevance router, byte-identical for graphs without the new options.
if (deps.routeTurnStage) {
builder = builder.addFunction('RouteTurn', deps.routeTurnStage, STAGE_IDS.PICK_ENTRY, 'Route the turn start: declared rules, then the scorer, else offer a menu (cascade)');
}
else if (deps.pickEntryStage) {
builder = builder.addFunction('PickEntry', deps.pickEntryStage, STAGE_IDS.PICK_ENTRY, 'Pick the starting skill by relevance to the message (entryByRelevance)');
}
// ── The results layer — conditional mount at the LOOP HEAD (honesty layer 3).
// Mounted before the window strategy and made the loop target below: it reads
// the batch ToolCalls just ran before any window strategy folds it away.
// Absent → the builder, untouched.
builder = mountResultsLayer(builder, deps.resultsLayer);
// Window strategy — mounted immediately before the current loop target, and
// it BECOMES the loop target below. That placement is the whole design: the
// window changes once per iteration boundary, before the injection engine
// re-evaluates triggers and before the slots compose, so nothing downstream
// reasons over a window the model will not be sent.
if (deps.windowStage) {
builder = builder.addFunction('Compact', deps.windowStage.run, STAGE_IDS.COMPACT, `Apply the '${deps.windowStage.strategyName}' window strategy to the live window`);
}
// Where `tool-calls` loops back to. With a window strategy the head moves
// one stage earlier; without it, this is the id it has always been.
// The results layer (honesty layer 3) heads the loop when armed: every
// tool-calls loop enters it first, so it reads the batch just run before any
// window strategy folds it away (`honesty/mounts.ts` · `mountResultsLayer`).
const loopTarget = deps.resultsLayer !== undefined
? RESULTS_LOOP_TARGET
: deps.windowStage
? STAGE_IDS.COMPACT
: SUBFLOW_IDS.INJECTION_ENGINE;
builder = builder
// Injection Engine — evaluates every Injection's trigger once
// per iteration; writes activeInjections[] to parent scope for
// the slot subflows to consume. Skipped if no injections were
// registered (no observable difference, just one more no-op
// subflow boundary).
.addSubFlowChartNext(SUBFLOW_IDS.INJECTION_ENGINE, deps.injectionEngineSubflow, 'Injection Engine', {
inputMapper: (parent) => ({
iteration: parent.iteration,
// The turn's action budget (9.57.0), threaded exactly as the cache
// mount threads it — so `activeWhen` can gate on how much room is
// left, and a templated instruction can SAY it.
maxIterations: parent.maxIterations ?? deps.maxIterations,
userMessage: parent.userMessage,
history: parent.history,
lastToolResult: parent.lastToolResult,
// The WHOLE batch, in call order (9.16.0) — `lastToolResult` is its
// last entry. Routes/`on-tool-return` triggers see every parallel call.
toolResults: parent.toolResults,
activatedInjectionIds: parent.activatedInjectionIds ?? [],
// Last turn's per-slot active set so the engine's Delta stage can
// diff "what changed per slot". Persists across the ReAct loop in
// the (flat) parent scope; empty on turn 1.
priorActiveByslot: parent.activeByslot ?? EMPTY_ACTIVE_BY_SLOT,
// Skill-graph cursor as of the previous iteration — the `from`-gate the
// route triggers compare against. Undefined on cold start / no graph.
currentSkillId: parent.currentSkillId,
// The `read_skill` pick the gate accepted last iteration — the model's
// own move through the graph (one-shot; the tool-calls stage rewrites it).
pendingSkillPick: parent.pendingSkillPick,
// Relevance entry ranking (from an entry scorer) — read by defineRelevanceHint.
entryScores: parent.entryScores,
entryScorer: parent.entryScorer,
// The turn-start verdict (SG-C) — written once by RouteTurn; the
// resolver consumes it on iteration 1, the menu hint reads `offered`.
turnRoute: parent.turnRoute,
// The step pointer as of the previous iteration (9.18.0) — a
// readonly input for the Evaluate re-key. Threaded only for agents
// with a stepped skill, so every other agent's engine args are the
// exact bytes they always were.
...(deps.hasSteps === true && { stepPointer: parent.stepPointer }),
// The typed tool-effects carriers (9.19.0) — value-conditional (the
// `resolvedModel` precedent): the keys exist only after a tool
// actually granted one, so every other run's engine args are the
// exact bytes they always were.
...(parent.pendingToolTransition !== undefined && {
pendingToolTransition: parent.pendingToolTransition,
}),
...(parent.instructionLeases !== undefined && {
instructionLeases: parent.instructionLeases,
}),
// The mount kernel's engagement state (9.58.0) — readonly input for
// the Evaluate advance. Threaded only for agents built with
// `.maps()`, so every other agent's engine args are the exact
// bytes they always were.
...(deps.engagementPlan !== undefined &&
parent.mapEngagement !== undefined && {
mapEngagement: parent.mapEngagement,
}),
// The kernel's PER-PASS pick feed (9.59.0) — every read_skill pick the
// gate accepted last iteration. Threaded beside the engagement state and
// under the same gate, so an agent without `.maps()` is unchanged.
...(deps.engagementPlan !== undefined && {
acceptedSkillPicks: parent.acceptedSkillPicks ?? [],
}),
// The kernel's SERVED carrier (9.59.0) — what actually reached the
// wire last pass, feeding the idle test's first clause. Its own key,
// because the Delta round-trip is empty in the grouped chart.
...(deps.engagementPlan !== undefined && {
servedInjectionIds: parent.servedInjectionIds ?? [],
}),
// A composition member's run, for the Gather stage's skill-visibility
// check (`memberRun.ts`) — value-conditional: the agent's own runs
// cross no key.
...memberRunArg(parent.memberRun),
}),
// Carry activeByslot back to parent so next turn's inputMapper can
// feed it as priorActiveByslot (the Delta round-trip). currentSkillId is
// the advanced cursor for the next iteration (scalar → always replaced).
outputMapper: (sf) => ({
activeInjections: sf.activeInjections,
activeByslot: sf.activeByslot,
currentSkillId: sf.nextSkillCursor,
// The role-hidden skill ids this iteration, resolved by the engine's
// Gather stage before it decided anything — onto the parent key every
// model-facing composer and the read_skill gate read. Value-
// conditional, so an agent without per-role visibility is unchanged.
...(sf.hiddenSkillIds !== undefined && { hiddenSkillIds: sf.hiddenSkillIds }),
// The re-keyed pointer (9.18.0), back onto the parent's key — a
// top-level ARRAY, so `arrayMerge: Replace` below sets it wholesale
// (a bare object here would shallow-merge and APPEND the nested
// `skipped` array across tenures; see StepPointerCarrier).
...(deps.hasSteps === true && { stepPointer: sf.nextStepPointer }),
// The lease tenure sweep's survivors (9.19.0), back onto the key
// the tool-calls stage appends to — the sweep is what makes lease
// death PERMANENT (a cyclic graph must not resurrect a dead lease
// on re-entry). Value-conditional: Evaluate writes it on every
// pass the parent key exists, and never before a first grant, so
// every other agent's mapper output is the exact bytes it was.
...(sf.nextInstructionLeases !== undefined && {
instructionLeases: sf.nextInstructionLeases,
}),
// The advanced engagement state (9.58.0), back onto the parent's
// key — the cursor/lease alias round trip. Value-conditional:
// Evaluate writes it on every pass `.maps()` is mounted, and never
// otherwise, so every other agent's mapper output is unchanged.
...(sf.nextMapEngagement !== undefined && {
mapEngagement: sf.nextMapEngagement,
}),
// The kernel's SERVED carrier (9.59.0), back onto the parent key.
...(sf.nextServedInjectionIds !== undefined && {
servedInjectionIds: sf.nextServedInjectionIds,
}),
// The engagement axis's TOOL suppression (9.59.0) — a parked map's
// tools come off the wire whatever the `scopeTools` posture. Written
// on every pass the kernel is mounted; absent otherwise, so every
// other agent's mapper output is the exact bytes it was.
...(sf.parkedToolNames !== undefined && {
parkedToolNames: sf.parkedToolNames,
}),
}),
// CRITICAL: footprintjs's default `applyOutputMapping`
// CONCATENATES arrays from subflow output with the parent's
// existing array values. Without `Replace`, the parent's
// `activeInjections` from iter N gets CONCATENATED with the
// subflow's iter N+1 fresh evaluation — producing
// 8 → 16 → 24 → 32 cumulative injections per turn.
arrayMerge: ArrayMergeMode.Replace,
})
// Declared milestone (9.90.0): the iteration boundary, stamped on the
// mount's first commit bundle so a recording carries it without the id.
.tag(...milestoneTagsFor(SUBFLOW_IDS.INJECTION_ENGINE));
// ── Messages-slot delivery — conditional mount (7.21) ───────────
// The one stage that lets declared content INTO the window. It has to sit
// here: after the engine knows what is active, and before any reader of
// `history` runs, so the projection, the cache indices and the request all
// describe one conversation. Omitted entirely when nothing could target the
// slot (no dead box, no write nobody makes).
if (deps.deliverStage) {
builder = builder.addFunction('Deliver', deps.deliverStage, STAGE_IDS.DELIVER, 'Deliver messages-slot injections into the window (role-checked, sequence-checked)');
}
builder = builder
// ── Context assembly: the 3 slots run in PARALLEL (selector fan-out) ──
// The slots are genuinely INDEPENDENT — each reads ONLY InjectionEngine's
// activeInjections + seed state, and each writes a DISJOINT output key
// (systemPromptInjections / messagesInjections / toolsInjections +
// dynamicToolSchemas). None reads another slot's output. Running them
// sequentially was an accident of chaining; the fork makes the execution
// tree tell the truth (and the Lens merge-tree renders the real shape).
// The selector picks ALL 3 every iteration (unconditional fan-out).
// failFast: true — a REQUIRED slot that throws aborts the whole turn,
// matching the old sequential-throw behavior. WITHOUT it the default
// Promise.allSettled would SWALLOW a failing slot and call the LLM with a
// half-built request (the documented request-assembly footgun).
// ── Context selector — THE one place Classic and Dynamic differ ──────
// The 3 slots are always selector BRANCHES (so they stay drawn in the
// chart in both modes); WHICH ones get selected per turn is the whole
// Classic-vs-Dynamic difference:
// • dynamic — pick all 3 EVERY turn (activations can change per turn:
// a skill fires, a rule matches, a tool-return steers the next turn).
// • classic — pick all 3 on the FIRST turn, then ONLY messages. The
// static slots aren't re-selected, so their turn-1 outputs persist in
// scope (that IS the cache — the flat builder has no per-turn reset),
// and only the message list rebuilds each iteration. The Lens shows
// this directly: after turn 1 only the Messages branch lights up.
.addSelectorFunction('Context', ((scope) => {
const firstTurn = (scope.iteration ?? 1) <= 1;
const includeStatic = reactMode === 'dynamic' || firstTurn;
return select(scope, [
{
when: () => includeStatic,
then: SUBFLOW_IDS.SYSTEM_PROMPT,
label: 'engineer system-prompt',
},
{ when: () => true, then: SUBFLOW_IDS.MESSAGES, label: 'engineer messages' },
{ when: () => includeStatic, then: SUBFLOW_IDS.TOOLS, label: 'engineer tools' },
]);
}), STAGE_IDS.CONTEXT, reactMode === 'classic'
? 'Assemble request context: messages every turn; system-prompt + tools cached after turn 1'
: 'Assemble request context: system-prompt + messages + tools (parallel)', { failFast: true })
// Each branch keeps its inputMapper + outputMapper + arrayMerge:Replace
// VERBATIM from the former sequential mounts. Replace (not concat) is
// load-bearing: the loopTo would otherwise accumulate injections/tools.
//
// Declared milestones (9.90.0): each slot mount carries `milestone:slot`
// via `SubflowMountOptions.tags` (footprintjs 9.21.1 — a branch mount has
// no cursor for `.tag()`); it lands on the mount's FIRST bundle.
.addSubFlowChartBranch(SUBFLOW_IDS.SYSTEM_PROMPT, deps.systemPromptSubflow, 'System Prompt', {
tags: milestoneTagsFor(SUBFLOW_IDS.SYSTEM_PROMPT),
inputMapper: (parent) => ({
userMessage: parent.userMessage,
iteration: parent.iteration,
// `.configure()`'s per-run system prompt, as seed committed it. The
// key is spread in ONLY when a resolver actually produced one, so an
// unconfigured agent seeds this subflow with the same keys as ever.
...(parent.resolvedInstructions !== undefined && {
instructions: parent.resolvedInstructions,
}),
activeInjections: withMemoryRecall(parent.activeInjections, parent, memoryIds),
}),
outputMapper: (sf) => ({ systemPromptInjections: sf.systemPromptInjections }),
arrayMerge: ArrayMergeMode.Replace,
})
.addSubFlowChartBranch(SUBFLOW_IDS.MESSAGES, deps.messagesSubflow, 'Messages', {
tags: milestoneTagsFor(SUBFLOW_IDS.MESSAGES),
inputMapper: (parent) => ({
messages: parent.history,
iteration: parent.iteration,
activeInjections: withMemoryRecall(parent.activeInjections, parent, memoryIds),
}),
outputMapper: (sf) => ({ messagesInjections: sf.messagesInjections }),
arrayMerge: ArrayMergeMode.Replace,
})
.addSubFlowChartBranch(SUBFLOW_IDS.TOOLS, deps.toolsSubflow, 'Tools', {
tags: milestoneTagsFor(SUBFLOW_IDS.TOOLS),
inputMapper: (parent) => ({
iteration: parent.iteration,
activeInjections: parent.activeInjections,
// The parked maps' tool names (9.59.0) — the tools slot holds these
// out of the STATIC list, which is the only place a parked map's
// tools can be reached on the default `scopeTools` posture.
parkedToolNames: parent.parkedToolNames,
// Integrity findings already filed this run (9.60.0) — the dedup
// seen-list, in under the alias (inputMapper keys are frozen inside
// the subflow; the slot writes the fresh list under the base key).
...(parent.integrityFindingIds !== undefined && {
priorIntegrityFindingIds: parent.integrityFindingIds,
}),
// The reader's settled windows (step T6b) and the app's control window,
// under `.time()` only and value-conditional: a turn with none crosses no
// key. See `AgentChartDeps.timeWindows`.
...(deps.timeWindows === true && timeWindowsArg(parent.findingsLedger)),
...(deps.timeWindows === true && timeClockArg(parent.findingsLedger)),
// The role-hidden skill ids the Injection Engine resolved THIS iteration
// (owner: `buildInjectionEngineSubflow.ts` · `hiddenSkillIdsOf`) — the
// menu is composed from the same answer the activation was refused by.
// Value-conditional: an agent without per-role visibility crosses no key.
...(parent.hiddenSkillIds !== undefined && {
hiddenSkillIds: parent.hiddenSkillIds,
}),
// The turn's result-check lines (step T8), under `.time()` only and
// value-conditional. See `AgentChartDeps.timeLimits`.
...(deps.timeLimits === true && timeLimitsArg(parent.findingsLedger)),
// Tool choice by classifier (9.105.0), under the arm only: the
// message the classifier reads, the rows so far (aliased — a mount
// input is frozen inside; the slot writes `toolChoices` fresh) and
// the wrap-up latch. Each value-conditional, so an armed agent that
// has no rows yet crosses no `priorToolChoices` key.
...(deps.hasToolChoice === true && {
userMessage: parent.userMessage,
...(parent.toolChoices !== undefined && { priorToolChoices: parent.toolChoices }),
...(parent.wrapUpAsked === true && { wrapUpAsked: true }),
...(parent.toolsOffAfterRevision === true && { toolsOffAfterRevision: true }),
}),
// The slot subflow reads these to build the per-iteration
// ToolDispatchContext when an external `.toolProvider()` is
// configured. Without them the provider sees activeSkillId
// = undefined every iteration, breaking skillScopedTools etc.
activatedInjectionIds: parent.activatedInjectionIds,
runIdentity: parent.runIdentity,
// The skill-graph cursor, for the per-iteration `read_skill` offer (8.5.0).
// ALREADY ADVANCED: the Injection Engine mounts before this branch and its
// outputMapper writes `currentSkillId = nextSkillCursor`, so this is the
// cursor the gate will use when the model answers — not last turn's.
currentSkillId: parent.currentSkillId,
// The turn-start verdict (SG-C) — the Compose stage leads read_skill's
// description with the menu while it is outstanding.
turnRoute: parent.turnRoute,
// The step pointer (9.18.0), ALREADY RE-KEYED — the same freshness
// rule the cursor line above documents. In this flat chart the
// engine outputMapper wrote it back onto `stepPointer`; the alias
// arm is for the grouped chart, where the fresh value lives under
// `nextStepPointer` beside a stale boundary input (the :336
// nextSkillCursor pattern) — one expression, correct in both.
...(deps.hasSteps === true && {
stepPointer: parent.nextStepPointer ?? parent.stepPointer,
}),
}),
outputMapper: (sf) => ({
toolsInjections: sf.toolsInjections,
// Pass merged tool schemas (registry + injection-supplied)
// back up so callLLM uses the right list for THIS iteration.
dynamicToolSchemas: sf.toolSchemas,
// The integrity dedup seen-list, back onto the parent key —
// value-conditional, so agents with no findings are byte-identical.
...(sf.integrityFindingIds !== undefined && {
integrityFindingIds: sf.integrityFindingIds,
}),
// The classifier's pick (9.105.0), back onto the parent key the
// call-llm stage appends the outcome row to. Value-conditional.
...(sf.toolChoices !== undefined && { toolChoices: sf.toolChoices }),
// The time layer's late line (step T6b), onto the key `callLLM` appends last to the
// request — under the reader's arm only (the slot writes it every composition then).
...(sf.timeLine !== undefined && { timeLine: sf.timeLine }),
}),
// Same array-concat hazard as InjectionEngine — replace, don't
// concatenate. Without Replace the deduped tool list re-acquires
// duplicates that providers reject.
arrayMerge: ArrayMergeMode.Replace,
})
.end();
// ── Skill-churn window (cache concern) ──────────────────────────
// UpdateSkillHistory stays in the MAIN loop (NOT inside sf-cache): the
// skillHistory rolling window must persist across iterations, so keeping
// it here lets it live in parent scope without round-tripping through the
// subflow. It feeds sf-cache's CacheGate churn check, and sits right where
// the tool-driven skill activation flows into it.
//
// CONDITIONAL MOUNT: only when skills are registered. With no skills the
// window records "no skill" every iteration and CacheGate's churn rule can
// never fire — so the stage is omitted entirely (no dead weight, no
// misleading box). Mirrors the read_skill auto-attach + NormalizeThinking.
if (deps.hasSkills) {
builder = builder.addFunction('UpdateSkillHistory', deps.updateSkillHistoryStage, STAGE_IDS.UPDATE_SKILL_HISTORY, 'Update skill-history rolling window for CacheGate churn detection');
}
builder = builder
// sf-cache: decideCacheMarkers → CacheGate → apply/skip, collapsed into
// ONE box. Pure provider-agnostic DECISION layer — reads the turn's state,
// outputs only the gated cacheMarkers (Replace, not concat, across the
// loop). The attached provider's CacheStrategy turns markers into wire
// format later. See buildCacheSubflow.ts.
// Findings uses stable tool definitions and late request-only DATA, so
// its changing ledger does not disable prefix cache markers.
.addSubFlowChartNext(SUBFLOW_IDS.CACHE, buildCacheSubflow(), 'Cache', {
inputMapper: (parent) => ({
// decideCacheMarkers inputs
activeInjections: parent.activeInjections ?? [],
iteration: parent.iteration ?? 1,
maxIterations: parent.maxIterations ?? deps.maxIterations,
userMessage: parent.userMessage ?? '',
...(parent.lastToolResult !== undefined && {
lastToolName: parent.lastToolResult?.toolName,
}),
cumulativeInputTokens: parent.totalInputTokens ?? 0,
systemPromptCachePolicy: deps.systemPromptCachePolicy,
cachingDisabled: parent.cachingDisabled ?? false,
// The window AS IT WILL BE SENT (7.21). A `CacheMarker{field:'messages'}`
// names a position in the request's message array, so it has to be
// computed against that array — post-window, post-delivery — and not
// against a count of injections, which is what it used to be and why
// the marker pointed at the wrong message.
history: parent.history ?? [],
// CacheGate inputs (read-only: skillHistory is updated in the main
// loop above, so it is NOT mapped back out)
recentHitRate: parent.recentHitRate,
skillHistory: parent.skillHistory ?? [],
}),
outputMapper: (sf) => ({ cacheMarkers: sf.cacheMarkers }),
arrayMerge: ArrayMergeMode.Replace,
})
// CallLLM emits the per-iteration `iteration_start` marker itself (no
// dedicated IterationStart stage — emitting is passive observability).
.addFunction('CallLLM', deps.callLLM, STAGE_IDS.CALL_LLM, 'LLM invocation')
// Declared milestone (9.90.0): the LLM turn — `milestone:llm-turn` on the
// stage's commit bundle, from the same table `milestoneFor` reads.
.tag(...milestoneTagsFor(STAGE_IDS.CALL_LLM));
// v2.14 — conditional NormalizeThinking sub-subflow. Mounted 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; matches the panel's design rule).
if (deps.thinkingSubflow) {
builder = builder.addSubFlowChartNext(SUBFLOW_IDS.THINKING, deps.thinkingSubflow, 'NormalizeThinking', {
inputMapper: (parent) => ({
rawThinking: parent.rawThinking,
iteration: parent.iteration,
}),
outputMapper: (sf) => ({
thinkingBlocks: sf.thinkingBlocks,
}),
// Replace not concatenate — fresh thinking per iteration
arrayMerge: ArrayMergeMode.Replace,
});
}
// ── The inputs layer — conditional mount (honesty layer 2) ───────────
// After the LLM call (and its thinking normalization), before Route: once
// per batch, and it acts only when Route's own predicate says the batch
// dispatches. The one helper both builders call; absent → untouched.
builder = mountInputsLayer(builder, deps.inputsLayer);
// Declared milestones (9.90.0) on the decider and its branches — a decider /
// branch has no cursor for `.tag()`, so each declares in its own `tags`.
let decider = builder
.addDeciderFunction('Route', deps.routeDecider, SUBFLOW_IDS.ROUTE, 'ReAct routing', {
tags: milestoneTagsFor(SUBFLOW_IDS.ROUTE),
})
.addPausableFunctionBranch('tool-calls', 'ToolCalls', deps.toolCallsHandler, 'Tool execution (pausable via pauseHere)',
// Branch-sourced loop: tool-calls loops back to the InjectionEngine so
// EVERY iteration re-evaluates triggers against the freshest context (the
// just-appended tool result). Sourced from the BRANCH (not the decider) so
// the chart reads honestly — `ToolCalls → InjectionEngine` loops, `Final`
// terminates. Survives pause/resume (human-in-the-loop tool approval): the
// engine resolves the subflow loop target on resume — footprintjs
// FlowChartExecutor.resume + test/lib/pause/resume-branch-loop-subflow.
{ loopTo: loopTarget, tags: milestoneTagsFor('tool-calls') });
// ── The schema re-ask — conditional mount (7.26) ────────────────
// The second looping branch, and it loops to the SAME target the tool branch
// does. That is the whole design: a re-ask re-enters the ordinary loop, so
// the corrective turn is engineered, cached, sent and recorded exactly like
// any other — its own iteration bracket, its own cost tick. Omitted entirely
// for an agent that did not ask for retries.
if (deps.outputRetryStage) {
decider = decider.addFunctionBranch(STAGE_IDS.OUTPUT_RETRY, 'SchemaRetry', deps.outputRetryStage, 'Answer failed the output schema — put the correction back and ask again', { loopTo: loopTarget, tags: milestoneTagsFor(STAGE_IDS.OUTPUT_RETRY) });
}
// ── The unfinished-steps nudge — conditional mount (9.18.0) ─────────
// The SchemaRetry mechanism verbatim, one branch over: same loop target,
// same "a re-ask is one more ordinary turn" reasoning, same conditional
// mount (no stepped skill → no branch, no stage, no event).
if (deps.stepNudgeStage) {
decider = decider.addFunctionBranch(STAGE_IDS.STEP_NUDGE, 'StepNudge', deps.stepNudgeStage, 'Answer left declared steps unrun — one teaching nudge goes back (once per turn)', { loopTo: loopTarget, tags: milestoneTagsFor(STAGE_IDS.STEP_NUDGE) });
}
// ── The evidence recheck — conditional mount (9.35.0) ───────────────
// The SchemaRetry mechanism a third time: same loop target, same "a re-ask
// is one more ordinary turn" reasoning, same conditional mount. Absent for
// `posture: 'assist'` (which records and never loops) and for every agent
// that did not ask for the check at all.
if (deps.negativeRecheckStage) {
decider = decider.addFunctionBranch(STAGE_IDS.NEGATIVE_RECHECK, 'NegativeCitationCorrection', deps.negativeRecheckStage, 'Correct a citation once with tools disabled', { loopTo: loopTarget, tags: milestoneTagsFor(STAGE_IDS.NEGATIVE_RECHECK) });
}
if (deps.evidenceRecheckStage) {
decider = decider.addFunctionBranch(STAGE_IDS.EVIDENCE_RECHECK, 'EvidenceRecheck', deps.evidenceRecheckStage, 'Answer stated values no tool result carried — naming them back for one revision', { loopTo: loopTarget, tags: milestoneTagsFor(STAGE_IDS.EVIDENCE_RECHECK) });
}
// ── The out-of-budget wrap-up — conditional mount (9.56.0) ─────────
// The SchemaRetry mechanism a fourth time: same loop target, same "a last
// ask is one more ordinary turn" reasoning. What is different is WHY it
// cannot loop forever — not a spent latch but an empty tool list, withheld
// at request assembly, so the call it buys has nothing to ask for.
if (deps.wrapUpStage) {
decider = decider.addFunctionBranch(STAGE_IDS.WRAP_UP, 'WrapUp', deps.wrapUpStage, 'Action budget exhausted — one last call with the tools withheld, for a real answer', { loopTo: loopTarget, tags: milestoneTagsFor(STAGE_IDS.WRAP_UP) });
}
builder = decider
.addSubFlowChartBranch(SUBFLOW_IDS.FINAL, finalBranchChart, 'Final', {
tags: milestoneTagsFor(SUBFLOW_IDS.FINAL),
// Pass through the read-only state the sub-chart needs;
// OMIT keys the sub-chart writes (finalContent, newMessages)
// — passing those via inputMapper would freeze them as args.
inputMapper: (parent) => {
const { finalContent: _f, newMessages: _nm, ...rest } = parent;
void _f;
void _nm;
if (deps.hasAnswerValidation === true) {
// This stage writes the commit flag; passing it as an argument
// would freeze it before capture. The report remains read-only.
const { answerValidationCommitted: _committed, ...input } = rest;
void _committed;
return input;
}
return rest;
},
outputMapper: (sf) => finalBranchOutput(sf, deps.hasAnswerValidation === true),
// With the branch-sourced loop, `final` is a terminal LEAF — it ends the
// run on its own (no decider `next` to suppress). propagateBreak is kept
// so BreakFinal's $break() still surfaces a terminal onBreak signal to the
// parent (observability) and stays correct if a decider-level `next` is
// ever reintroduced.
propagateBreak: true,
})
.setDefault(SUBFLOW_IDS.FINAL)
.end();
// The ReAct loop is now sourced from the `tool-calls` branch (the
// `{ loopTo }` above), not the decider — so `Final` is a plain terminal leaf
// and the chart draws `ToolCalls → InjectionEngine` for the loop edge.
return builder.build();
}
//# sourceMappingURL=buildAgentChart.js.map