agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
932 lines • 61.2 kB
JavaScript
"use strict";
/**
* buildDynamicAgentChart — the Dynamic-ReAct agent chart, where the
* whole LLM turn (context engineering + the call) is ONE `sf-llm-call`
* subflow, exactly like the `LLMCall` primitive produces.
*
* WHY a second builder (vs `buildAgentChart`)
* ───────────────────────────────────────────
* `buildAgentChart` mounts the LLM as a flat `call-llm` STAGE with the
* slot subflows as its siblings. That renders as nothing in Lens — a
* bare stage is dropped by the subflow-level collapser, so the slots
* have no LLM card to fold into and the chart comes up empty.
*
* This builder wraps that same region in an `sf-llm-call` SUBFLOW. The
* payoff is purely structural — Lens already maps `sf-llm-call → LLM
* group` (same boundary `LLMCall` produces), so the Dynamic agent
* renders as an LLM group with its slots inside, a peer Tool node, and
* the loop arc, with ZERO Lens-specific special-casing.
*
* The data flow is IDENTICAL to `buildAgentChart` — every stage handler
* + slot subflow is reused verbatim from the same `AgentChartDeps`. The
* ONLY new thing is the subflow boundary, which means:
*
* • A small inner seed (`dynamicTurnSeed`) initialises the per-turn
* working keys the OUTER seed used to set, since the inner subflow
* gets a fresh scope each loop re-entry.
* • Cross-iteration accumulators (token totals, cost counters,
* skill-history) round-trip out→in: the boundary `outputMapper`
* bubbles them to the outer scope, and the next iteration's
* `inputMapper` feeds them back under `prior*` aliases (because
* keys passed via `inputMapper` are FROZEN inside the subflow —
* `ScopeFacade.setValue` throws on them — so the writable working
* key must have a different name from the read-only input).
*
* Chart shape (mirrors the diagram the team locked):
*
* Initialize
* → [memory READ subflows]
* → sf-llm-call (SUBFLOW — same boundary LLMCall produces)
* dynamicTurnSeed → InjectionEngine
* → Context (selector, PARALLEL fan-out, failFast)
* ⇉ {System Prompt ‖ Messages ‖ Tools} → converge
* → UpdateSkillHistory → Cache (sf-cache subflow)
* → CallLLM (emits iteration_start) → [NormalizeThinking]
* → Route (decider)
* ├─ tool-calls (pausable) → loopTo(sf-llm-call) ← branch-sourced loop
* └─ sf-final (the answer) → terminal leaf
*
* Classic ReAct keeps using `buildAgentChart` until its own shape is
* designed — this builder is Dynamic-only.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildDynamicAgentChart = void 0;
const advanced_1 = require("footprintjs/advanced");
const footprintjs_1 = require("footprintjs");
const conventions_js_1 = require("../../lib/conventions.js");
const buildInjectionEngineSubflow_js_1 = require("../../injection-engine/buildInjectionEngineSubflow.js");
const define_types_js_1 = require("../../memory/define.types.js");
const define_js_1 = require("../../memory/define.js");
const mountMemoryPipeline_js_1 = require("../../memory/wire/mountMemoryPipeline.js");
const memoryRecallInjections_js_1 = require("./memoryRecallInjections.js");
const breakFinal_js_1 = require("./stages/breakFinal.js");
const prepareFinal_js_1 = require("./stages/prepareFinal.js");
const failFastRecord_js_1 = require("../../reliability/failFastRecord.js");
const buildCacheSubflow_js_1 = require("./buildCacheSubflow.js");
const buildAgentChart_js_1 = require("./buildAgentChart.js");
const mounts_js_1 = require("./honesty/mounts.js");
const memberRun_js_1 = require("../memberRun.js");
/**
* Inner seed for the `sf-llm-call` subflow. Initialises the per-turn
* working keys (the ones the OUTER seed set on the flat chart) and
* copies the cross-iteration accumulators from their read-only `prior*`
* inputs into the writable working keys.
*
* Why the `prior*` indirection: `inputMapper` values are frozen inside
* the subflow (any `scope.set` on them throws). callLLM does
* `scope.totalInputTokens += usage` — so `totalInputTokens` must be a
* writable working key, seeded here from the frozen `priorTotalInputTokens`.
*/
function dynamicTurnSeed(scope) {
const args = scope.$getArgs();
// Cross-iteration accumulators — seed working keys from prior totals
// so they continue to accumulate across loop re-entries.
scope.totalInputTokens = args.priorTotalInputTokens ?? 0;
scope.totalOutputTokens = args.priorTotalOutputTokens ?? 0;
// Cache reads (9.110.0) — value-conditional both ways: seeded only when a
// provider has reported one, so a run on a provider that reports none
// crosses no new key.
if (args.priorTotalCacheReadTokens !== undefined) {
scope.totalCacheReadTokens = args.priorTotalCacheReadTokens;
}
scope.cumTokensInput = args.priorCumTokensInput ?? 0;
scope.cumTokensOutput = args.priorCumTokensOutput ?? 0;
scope.cumEstimatedUsd = args.priorCumEstimatedUsd ?? 0;
scope.costBudgetHit = args.priorCostBudgetHit ?? false;
scope.skillHistory = args.priorSkillHistory ?? [];
// The WINDOW, as a writable working key (7.21). It used to arrive as the
// read-only `history` input, which was fine while nothing inside the
// subflow changed it — the Deliver stage does. Seeding it here from
// `priorHistory` (and bubbling it out at the boundary) is what makes the
// one-past law hold in the grouped shape too: what this iteration sends is
// what the next iteration's window stage sees. The delivery ledger takes
// the same trip so a delivered message is not re-delivered next turn.
scope.history = args.priorHistory ?? [];
scope.deliveredMessageKeys = args.priorDeliveredMessageKeys ?? [];
if (args.priorEvidenceRecoveryUsed !== undefined) {
scope.evidenceRecoveryUsed = args.priorEvidenceRecoveryUsed;
}
// The classifier's rows (9.105.0), the `history` trip: the Tools branch
// inside this boundary reads them, the call-llm stage appends to them, and
// the boundary bubbles them out — so they arrive under an alias and become
// the writable working key here. Value-conditional: an unarmed agent, and
// an armed one on its first call, seeds no key.
if (args.priorToolChoices !== undefined)
scope.toolChoices = args.priorToolChoices;
// Carry presence across the fresh inner scope so a reply with no replay
// evidence can explicitly clear the outer turn's former evidence.
if (args.priorLatestReplay !== undefined)
scope.llmLatestReplay = args.priorLatestReplay;
// Per-iteration working keys — fresh each turn (slots + cache + callLLM
// populate these inside the subflow; nothing outside reads the
// injection arrays, so they stay subflow-internal).
scope.activeInjections = [];
scope.systemPromptInjections = [];
scope.messagesInjections = [];
scope.toolsInjections = [];
scope.cacheMarkers = [];
scope.llmLatestContent = '';
scope.llmLatestToolCalls = [];
scope.thinkingBlocks = [];
}
/**
* Build the Dynamic-ReAct agent chart from the shared `AgentChartDeps`.
*/
function buildDynamicAgentChart(deps) {
// 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 ─────────────────────────────────────
// Identical to buildAgentChart: PrepareFinal captures the turn
// payload, memory-write subflows persist it, BreakFinal terminates
// the ReAct loop. Lives in the OUTER chart (the final answer is a
// peer of the LLM turn, not part of it).
// 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` and `honesty/mounts.ts` ·
// `startFinalBranch`, the one choice and the one helper both builders use.
let finalBranchBuilder = (0, mounts_js_1.startFinalBranch)(deps.answerLayer, (0, prepareFinal_js_1.prepareFinalFor)(deps), deps.structureRecorders);
for (const m of deps.memories) {
if (m.write) {
finalBranchBuilder = (0, mountMemoryPipeline_js_1.mountMemoryWrite)(finalBranchBuilder, {
pipeline: {
read: (0, define_js_1.unwrapMemoryFlowChart)(m.read),
write: (0, define_js_1.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 ? breakFinal_js_1.breakFinalWithValidationStage : breakFinal_js_1.breakFinalStage, 'break-final', 'Terminate the ReAct loop')
.build();
// ── Inner sf-llm-call subflow ────────────────────────────────
// The full context-engineering + call region. Every mount below is
// copied verbatim from buildAgentChart — only the PARENT scope is
// now the sf-llm-call scope instead of the flat agent scope, and
// the keys those mappers read are all present there (read-only keys
// via the boundary inputMapper, working keys via dynamicTurnSeed).
let inner = (0, footprintjs_1.flowChart)('TurnSeed', dynamicTurnSeed, 'turn-seed', {
...(deps.structureRecorders !== undefined && {
structureRecorders: [...deps.structureRecorders],
}),
// The `LLMCall:` prefix is DELIBERATE and load-bearing: Lens reads it
// to render this subflow as an LLM group (the keystone goal), mirroring
// the marker LLMCall.ts emits. The agent-ness is carried by the OUTER
// chart's `Agent: ReAct loop` description — so this does NOT mislabel
// the agent boundary (confirmed in the proposal's 7-person review).
description: 'LLMCall: invocation internals',
})
.addSubFlowChartNext(conventions_js_1.SUBFLOW_IDS.INJECTION_ENGINE, deps.injectionEngineSubflow, 'Injection Engine', {
// NOTE: `history` here is the writable working key `dynamicTurnSeed`
// sets from `priorHistory` — not the frozen boundary input. See the
// Deliver mount below for why that indirection exists.
inputMapper: (parent) => ({
iteration: parent.iteration,
// The turn's action budget (9.57.0) — the same line the flat chart
// and the cache mount carry, so `ctx` is one shape in both charts.
maxIterations: parent.maxIterations ?? deps.maxIterations,
userMessage: parent.userMessage,
history: parent.history,
lastToolResult: parent.lastToolResult,
// The WHOLE batch, in call order (9.16.0) — crossed into sf-llm-call by
// the outer boundary below, same as lastToolResult.
toolResults: parent.toolResults,
activatedInjectionIds: parent.activatedInjectionIds ?? [],
// Last turn's per-slot active set for the engine's Delta stage. In the
// grouped chart the sf-llm-call scope re-seeds each turn, so this is
// not yet carried across turns — Delta degrades to "all added" here
// (the flat/default chart carries it via the persistent parent scope).
priorActiveByslot: parent.activeByslot ?? buildInjectionEngineSubflow_js_1.EMPTY_ACTIVE_BY_SLOT,
// Skill-graph cursor from the previous iteration (carried into sf-llm-call
// by its outer boundary below). The `from`-gate for the route triggers.
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) — crossed into sf-llm-call by the outer
// boundary below; the resolver consumes it on iteration 1.
turnRoute: parent.turnRoute,
// The step pointer as of the previous iteration (9.18.0) — the
// sf-llm-call boundary's readonly input, for the Evaluate re-key.
...(deps.hasSteps === true && { stepPointer: parent.stepPointer }),
// The typed tool-effects carriers (9.19.0) — value-conditional (the
// `resolvedModel` precedent): present only after a tool granted one,
// crossed into sf-llm-call by the outer boundary below.
...(parent.pendingToolTransition !== undefined && {
pendingToolTransition: parent.pendingToolTransition,
}),
...(parent.instructionLeases !== undefined && {
instructionLeases: parent.instructionLeases,
}),
// The mount kernel's engagement state (9.58.0) — the sf-llm-call
// boundary's readonly input, for the Evaluate advance (the
// stepPointer discipline verbatim).
...(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.
...(0, memberRun_js_1.memberRunArg)(parent.memberRun),
}),
outputMapper: (sf) => ({
activeInjections: sf.activeInjections,
activeByslot: sf.activeByslot,
// Advanced cursor — bubbled up under its own key (sf-llm-call's
// `currentSkillId` is a readonly input here), then mapped onto the
// ReAct parent's mutable currentSkillId by the outer outputMapper.
nextSkillCursor: sf.nextSkillCursor,
// The role-hidden skill ids this iteration (9.86.0) — first hop, onto
// the sf-llm-call scope (the tools slot reads them there); the
// boundary's own outputMapper carries them the rest of the way to the
// ToolCalls stage. Resolved by the engine's Gather stage before it
// decided anything. Value-conditional.
...(sf.hiddenSkillIds !== undefined && { hiddenSkillIds: sf.hiddenSkillIds }),
// The re-keyed step pointer (9.18.0) — same alias discipline as the
// cursor one line up, same round trip: out under its own key, mapped
// onto the ReAct parent's `stepPointer` by the outer outputMapper.
// A top-level ARRAY on purpose (Replace sets it wholesale; a bare
// object would shallow-merge — see StepPointerCarrier).
...(deps.hasSteps === true && { nextStepPointer: sf.nextStepPointer }),
// The lease tenure sweep's survivors (9.19.0) — first hop of the
// same round trip (the outer boundary maps them onto the ReAct
// parent's `instructionLeases`). The sweep makes lease death
// PERMANENT: a cyclic graph must not resurrect a dead lease when
// the cursor re-enters the skill that granted it. Value-conditional
// — never written before a first grant.
...(sf.nextInstructionLeases !== undefined && {
nextInstructionLeases: sf.nextInstructionLeases,
}),
// The advanced engagement state (9.58.0) — first hop of the same
// alias round trip; the outer boundary maps it onto the ReAct
// parent's `mapEngagement`. Value-conditional — never written
// unless `.maps()` is mounted.
...(sf.nextMapEngagement !== undefined && {
nextMapEngagement: sf.nextMapEngagement,
}),
// The kernel's SERVED carrier (9.59.0) — first hop.
...(sf.nextServedInjectionIds !== undefined && {
nextServedInjectionIds: sf.nextServedInjectionIds,
}),
// The engagement axis's TOOL suppression (9.59.0) — first hop of the
// same round trip. Value-conditional, like everything beside it.
...(sf.parkedToolNames !== undefined && {
parkedToolNames: sf.parkedToolNames,
}),
}),
arrayMerge: advanced_1.ArrayMergeMode.Replace,
})
// Declared milestone (9.90.0): on the inner (drilled) log this mount is the
// iteration boundary, exactly as `milestoneFor` classifies it.
.tag(...(0, conventions_js_1.milestoneTagsFor)(conventions_js_1.SUBFLOW_IDS.INJECTION_ENGINE));
// ── Messages-slot delivery — conditional mount (7.21) ───────────
// Same placement as the flat chart (after the engine, before anything reads
// the window) but it forced a structural change here: until now `history`
// crossed the sf-llm-call boundary as a READ-ONLY input, and a stage inside
// could not write it (`ScopeFacade.setValue` throws on an inputMapper key).
// Delivery has to write it — a delivered message that lived only inside the
// subflow would be discarded at the boundary and the next iteration would
// send a window the recording never described. So `history` now takes the
// same `prior*` round-trip the token accumulators take: in as
// `priorHistory`, copied to a writable `history` by `dynamicTurnSeed`,
// bubbled back out below.
if (deps.deliverStage) {
inner = inner.addFunction('Deliver', deps.deliverStage, conventions_js_1.STAGE_IDS.DELIVER, 'Deliver messages-slot injections into the window (role-checked, sequence-checked)');
}
inner = inner
// ── Context assembly: the 3 slots run in PARALLEL (selector fan-out) ──
// Identical to buildAgentChart's fork, just nested inside the sf-llm-call
// inner chart. The slots are independent (each reads only InjectionEngine's
// activeInjections + turn-seed state, each writes a disjoint output key),
// so concurrent execution is faithful. failFast: true — a required slot
// that throws aborts the turn (the default allSettled would swallow it).
.addSelectorFunction('Context', ((scope) => (0, footprintjs_1.select)(scope, [
{ when: () => true, then: conventions_js_1.SUBFLOW_IDS.SYSTEM_PROMPT, label: 'engineer system-prompt' },
{ when: () => true, then: conventions_js_1.SUBFLOW_IDS.MESSAGES, label: 'engineer messages' },
{ when: () => true, then: conventions_js_1.SUBFLOW_IDS.TOOLS, label: 'engineer tools' },
])), conventions_js_1.STAGE_IDS.CONTEXT, 'Assemble request context: system-prompt + messages + tools (parallel)', { failFast: true })
// Branch mappers + arrayMerge:Replace VERBATIM from the former sequential
// mounts (Replace is load-bearing — loopTo would otherwise accumulate).
//
// Declared milestones (9.90.0): each slot mount carries `milestone:slot`
// via `SubflowMountOptions.tags` (footprintjs 9.21.1), on its FIRST bundle
// of the inner (drilled) log.
.addSubFlowChartBranch(conventions_js_1.SUBFLOW_IDS.SYSTEM_PROMPT, deps.systemPromptSubflow, 'System Prompt', {
tags: (0, conventions_js_1.milestoneTagsFor)(conventions_js_1.SUBFLOW_IDS.SYSTEM_PROMPT),
inputMapper: (parent) => ({
userMessage: parent.userMessage,
iteration: parent.iteration,
// `.configure()`'s per-run system prompt, carried into sf-llm-call by
// the boundary mapper below. Spread in only when one was resolved.
...(parent.resolvedInstructions !== undefined && {
instructions: parent.resolvedInstructions,
}),
activeInjections: (0, memoryRecallInjections_js_1.withMemoryRecall)(parent.activeInjections, parent, memoryIds),
}),
outputMapper: (sf) => ({ systemPromptInjections: sf.systemPromptInjections }),
arrayMerge: advanced_1.ArrayMergeMode.Replace,
})
.addSubFlowChartBranch(conventions_js_1.SUBFLOW_IDS.MESSAGES, deps.messagesSubflow, 'Messages', {
tags: (0, conventions_js_1.milestoneTagsFor)(conventions_js_1.SUBFLOW_IDS.MESSAGES),
inputMapper: (parent) => ({
messages: parent.history,
iteration: parent.iteration,
activeInjections: (0, memoryRecallInjections_js_1.withMemoryRecall)(parent.activeInjections, parent, memoryIds),
}),
outputMapper: (sf) => ({ messagesInjections: sf.messagesInjections }),
arrayMerge: advanced_1.ArrayMergeMode.Replace,
})
.addSubFlowChartBranch(conventions_js_1.SUBFLOW_IDS.TOOLS, deps.toolsSubflow, 'Tools', {
tags: (0, conventions_js_1.milestoneTagsFor)(conventions_js_1.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) — the flat chart's line, read INSIDE
// sf-llm-call off the boundary's `findingsLedger`. See `AgentChartDeps.timeWindows`.
...(deps.timeWindows === true && (0, buildAgentChart_js_1.timeWindowsArg)(parent.findingsLedger)),
...(deps.timeWindows === true && (0, buildAgentChart_js_1.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). See `AgentChartDeps.timeLimits`.
...(deps.timeLimits === true && (0, buildAgentChart_js_1.timeLimitsArg)(parent.findingsLedger)),
// Tool choice by classifier (9.105.0) — the flat chart's three args,
// read INSIDE sf-llm-call: `userMessage` and `wrapUpAsked` are the
// boundary's own inputs, `toolChoices` is `dynamicTurnSeed`'s copy
// of the boundary's `priorToolChoices`. See `AgentChartDeps.hasToolChoice`.
...(deps.hasToolChoice === true && {
userMessage: parent.userMessage,
...(parent.toolChoices !== undefined && { priorToolChoices: parent.toolChoices }),
...(parent.wrapUpAsked === true && { wrapUpAsked: true }),
...(parent.toolsOffAfterRevision === true && { toolsOffAfterRevision: true }),
}),
activatedInjectionIds: parent.activatedInjectionIds,
runIdentity: parent.runIdentity,
// The skill-graph cursor, for the per-iteration `read_skill` offer (8.5.0).
// Inside sf-llm-call the advanced cursor lands under `nextSkillCursor` (the
// boundary's `currentSkillId` is a readonly INPUT here and still holds the
// PREVIOUS iteration's value, which would offer a stale menu). The Injection
// Engine mounts before this branch, so `nextSkillCursor` is already written.
currentSkillId: (parent.nextSkillCursor ?? 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) — the exact cursor pattern three lines
// up: the FRESH value lives under `nextStepPointer` (the engine
// wrote it this iteration; always a defined carrier when steps are
// on), the boundary's `stepPointer` is the previous iteration's
// readonly input. Threaded like this so the offer narrows on the
// very iteration a tenure begins — not one late.
...(deps.hasSteps === true && {
stepPointer: parent.nextStepPointer ?? parent.stepPointer,
}),
}),
outputMapper: (sf) => ({
toolsInjections: sf.toolsInjections,
dynamicToolSchemas: sf.toolSchemas,
...(sf.integrityFindingIds !== undefined && {
integrityFindingIds: sf.integrityFindingIds,
}),
// The classifier's pick (9.105.0) — first hop, onto the sf-llm-call
// working key the call-llm stage appends to; the boundary's own
// outputMapper carries it the rest of the way. 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 }),
}),
arrayMerge: advanced_1.ArrayMergeMode.Replace,
// STRUCTURE-ONLY merge target. When skills are off, UpdateSkillHistory
// is omitted, so the fan-out must converge onto sf-cache instead — the
// convergeAt target has to be a node that actually exists.
convergeAt: deps.hasSkills ? conventions_js_1.STAGE_IDS.UPDATE_SKILL_HISTORY : conventions_js_1.SUBFLOW_IDS.CACHE,
})
.end();
// ── Skill-churn window (cache concern) — conditional mount ───────
// Mounted only when skills are registered (see buildAgentChart for the full
// rationale: with no skills the window can never show churn, so the stage is
// dead weight). UpdateSkillHistory stays in the loop (skillHistory must
// persist across iterations); sf-cache is the pure decision layer.
if (deps.hasSkills) {
inner = inner.addFunction('UpdateSkillHistory', deps.updateSkillHistoryStage, conventions_js_1.STAGE_IDS.UPDATE_SKILL_HISTORY, 'Update skill-history rolling window for CacheGate churn detection');
}
inner = inner
// The findings offer re-decorates every tool per call — see
// buildAgentChart's mount. Closed over at build time.
.addSubFlowChartNext(conventions_js_1.SUBFLOW_IDS.CACHE, (0, buildCacheSubflow_js_1.buildCacheSubflow)(), 'Cache', {
inputMapper: (parent) => ({
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 — a messages marker's index is a
// position in THAT array. See buildAgentChart's mount for the why.
history: parent.history ?? [],
recentHitRate: parent.recentHitRate,
skillHistory: parent.skillHistory ?? [],
}),
outputMapper: (sf) => ({ cacheMarkers: sf.cacheMarkers }),
arrayMerge: advanced_1.ArrayMergeMode.Replace,
})
// CallLLM emits the per-iteration `iteration_start` marker itself (no
// dedicated IterationStart stage — emitting is passive observability).
.addFunction('CallLLM', deps.callLLM, conventions_js_1.STAGE_IDS.CALL_LLM, 'LLM invocation')
// Declared milestone (9.90.0): the LLM turn, on the inner log.
.tag(...(0, conventions_js_1.milestoneTagsFor)(conventions_js_1.STAGE_IDS.CALL_LLM));
if (deps.thinkingSubflow) {
inner = inner.addSubFlowChartNext(conventions_js_1.SUBFLOW_IDS.THINKING, deps.thinkingSubflow, 'NormalizeThinking', {
inputMapper: (parent) => ({
rawThinking: parent.rawThinking,
iteration: parent.iteration,
}),
outputMapper: (sf) => ({ thinkingBlocks: sf.thinkingBlocks }),
arrayMerge: advanced_1.ArrayMergeMode.Replace,
});
}
const llmCallSubflow = inner.build();
// ── Outer chart ──────────────────────────────────────────────
// Description prefix `Agent:` is the taxonomy marker Lens reads to
// flag this as a true agent boundary.
let builder = (0, footprintjs_1.flowChart)('Initialize', deps.seed, conventions_js_1.STAGE_IDS.SEED, {
...(deps.structureRecorders !== undefined && {
structureRecorders: [...deps.structureRecorders],
}),
description: 'Agent: ReAct loop',
});
// Memory READ subflows — TURN_START timing (once per turn, OUTSIDE
// the LLM-call loop body). Each writes `memoryInjection_${id}` to the
// outer scope; the boundary inputMapper below threads those into the
// subflow so the slots consume them.
for (const m of deps.memories) {
builder = (0, mountMemoryPipeline_js_1.mountMemoryRead)(builder, {
pipeline: {
read: (0, define_js_1.unwrapMemoryFlowChart)(m.read),
...(m.write !== undefined && { write: (0, define_js_1.unwrapMemoryFlowChart)(m.write) }),
},
identityKey: 'runIdentity',
turnNumberKey: 'turnNumber',
contextTokensKey: 'contextTokensRemaining',
injectionKey: (0, define_types_js_1.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: (0, define_types_js_1.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 — once per turn on the OUTER scope, before the
// sf-llm-call loop (its loop target). ONE stage, two possible occupants,
// same id (recorded structures stay stable): RouteTurn is the SG-C cascade;
// PickEntry the 9.x relevance router, byte-identical without the new options.
if (deps.routeTurnStage) {
builder = builder.addFunction('RouteTurn', deps.routeTurnStage, conventions_js_1.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, conventions_js_1.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).
// The flat chart's twin, through the same helper: on the OUTER chart, before
// the window strategy, and the loop target below. Absent → untouched.
builder = (0, mounts_js_1.mountResultsLayer)(builder, deps.resultsLayer);
// Window strategy — the OUTER chart, immediately before sf-llm-call, and it
// becomes the loop target below. It cannot live inside sf-llm-call: the
// window crosses that boundary as a read-only inputMapper arg and is not in
// the outputMapper, so a change written in there would be discarded every
// iteration. Out here it edits the window the next turn is seeded from.
if (deps.windowStage) {
builder = builder.addFunction('Compact', deps.windowStage.run, conventions_js_1.STAGE_IDS.COMPACT, `Apply the '${deps.windowStage.strategyName}' window strategy to the live window`);
}
// 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
? mounts_js_1.RESULTS_LOOP_TARGET
: deps.windowStage
? conventions_js_1.STAGE_IDS.COMPACT
: conventions_js_1.SUBFLOW_IDS.LLM_CALL;
const withLlmCall = builder
.addSubFlowChartNext(conventions_js_1.SUBFLOW_IDS.LLM_CALL, llmCallSubflow, 'LLM', {
inputMapper: (parent) => {
const p = parent;
// Per-memory injection content the slots consume inside.
const memoryKeys = {};
for (const m of deps.memories) {
const key = (0, define_types_js_1.memoryInjectionKey)(m.id);
memoryKeys[key] = p[key];
}
return {
// Read-only working inputs (stages read, never write these).
userMessage: p.userMessage,
...((deps.evidenceRecheckStage !== undefined ||
deps.negativeRecheckStage !== undefined) &&
p.evidenceRecovery !== undefined && {
evidenceRecovery: p.evidenceRecovery,
priorEvidenceRecoveryUsed: p.evidenceRecoveryUsed,
}),
iteration: p.iteration,
maxIterations: p.maxIterations,
runIdentity: p.runIdentity,
// A composition member's run, crossed into sf-llm-call so the engine's
// Gather stage checks skill visibility under the MEMBER's caller, not
// under no identity at all. Value-conditional: the agent's own runs
// cross no key.
...(p.memberRun !== undefined && { memberRun: p.memberRun }),
cachingDisabled: p.cachingDisabled,
recentHitRate: p.recentHitRate,
activatedInjectionIds: p.activatedInjectionIds,
lastToolResult: p.lastToolResult,
// The iteration's whole tool batch (9.16.0) — same direct
// cross-iteration read as lastToolResult, of which it is the plural.
toolResults: p.toolResults,
// Skill-graph cursor carried into sf-llm-call (like activatedInjectionIds
// / lastToolResult — a direct cross-iteration read, not a prior* alias).
currentSkillId: p.currentSkillId,
// The model's accepted `read_skill` pick — written by tool-calls on the
// OUTER chart, read by the injection engine inside this boundary.
pendingSkillPick: p.pendingSkillPick,
// Relevance entry ranking — carried in so defineRelevanceHint can read it.
entryScores: p.entryScores,
entryScorer: p.entryScorer,
// The turn-start verdict (SG-C) — written once by RouteTurn on the
// OUTER scope, read inside by the engine mapper and the tools slot.
turnRoute: p.turnRoute,
// The step pointer (9.18.0) — a direct cross-iteration read, like
// currentSkillId one block up: the outer key holds the value the
// LAST iteration's boundary bubbled out (plus tool-calls' moves).
...(deps.hasSteps === true && { stepPointer: p.stepPointer }),
// The typed tool-effects carriers (9.19.0) — written by tool-calls
// on the OUTER scope, read inside by the engine mapper. Value-
// conditional: the keys exist only after a tool granted one.
...(p.pendingToolTransition !== undefined && {
pendingToolTransition: p.pendingToolTransition,
}),
...(p.instructionLeases !== undefined && {
instructionLeases: p.instructionLeases,
}),
// The mount kernel's engagement state (9.58.0) — a direct
// cross-iteration read like the step pointer one block up.
...(deps.engagementPlan !== undefined &&
p.mapEngagement !== undefined && { mapEngagement: p.mapEngagement }),
// The integrity dedup seen-list (9.60.0) — crosses into
// sf-llm-call so the tools slot's compose backstop files one
// finding per defect per RUN, not per pass. Value-conditional.
...(p.integrityFindingIds !== undefined && {
integrityFindingIds: p.integrityFindingIds,
}),
// The window ledger (9.60.0) — CallLLM's dangling-reference check
// reads which grounds left the window, and CallLLM lives INSIDE
// this boundary in the grouped chart. Without this line the check
// sees an empty ledger every pass and reports a healthy run, which
// is worse than not running it at all. Value-conditional: an agent
// with no window strategy crosses no new key.
...(p.compactions !== undefined && { compactions: p.compactions }),
// The findings ledger (9.101.0, step 3) — the `compactions` lesson
// one line up, again: CallLLM composes the ledger piece and the
// wire-only collapse from `findingsLedger`, and CallLLM lives INSIDE
// this boundary in the grouped chart, while the key is written by
// tool-calls and the Route decider on the OUTER scope. Without this
// line the piece is silently empty and nothing collapses — the same
// agent, same declarations, served less because of a chart shape it
// did not choose. Value-conditional: an unarmed agent, and an armed
// one whose model declared nothing, crosses no new key. Reads only
// inside (the writes are outside), so no outputMapper line is needed.
...(p.findingsLedger !== undefined && { findingsLedger: p.findingsLedger }),
// The declared ontology (9.106.0) — the same lesson: `callLLM`
// composes the ontology piece from the run constant `ontology`,
// which seed wrote on the OUTER scope, and `callLLM` lives INSIDE
// this boundary. Gated on the arm AND value-conditional, so an
// unarmed agent crosses no new key and reads none. Read-only
// inside, so no outputMapper line is needed.
...(deps.hasOntology === true && p.ontology !== undefined && { ontology: p.ontology }),
// The classifier's rows (9.105.0) — in under the alias, because the
// call-llm stage WRITES the key inside this boundary (the `history`
// shape, not the read-only `findingsLedger` one). Value-conditional.
...(p.toolChoices !== undefined && { priorToolChoices: p.toolChoices }),
...(p.llmLatestReplay !== undefined && { priorLatestReplay: p.llmLatestReplay }),
// The kernel's PER-PASS pick feed (9.59.0) — written by tool-calls
// on the OUTER scope, read by the engine mapper INSIDE this
// boundary. Same gate as the engagement state above.
...(deps.engagementPlan !== undefined && {
acceptedSkillPicks: p.acceptedSkillPicks ?? [],
}),
// The kernel's SERVED carrier (9.59.0) — outer key into
// sf-llm-call, so the inner engine mapper one level down can read
// it. Same gate as the engagement state above.
...(deps.engagementPlan !== undefined && {
servedInjectionIds: p.servedInjectionIds ?? [],
}),
// The escalation flip (9.19.0) — written by tool-calls on the
// OUTER scope, read by callLLM INSIDE this boundary (`brainFor`'s
// second argument). Gated on the policy being declared, so every
// other agent's boundary args are the exact bytes they always
// were; seed writes the key from turn start, so the value-spread
// is stable within a run.
...(deps.hasEscalation === true &&
p.skillEscalated !== undefined && { skillEscalated: p.skillEscalated }),
// The out-of-budget wrap-up (9.56.0) — written by the WrapUp branch
// on the OUTER scope, read by callLLM INSIDE this boundary, which is
// where the request is assembled and therefore where the tools come
// off. Value-conditional: the key exists only on the one turn that
// ran out of budget, so every other boundary's args are the exact
// bytes they always were.
...(p.wrapUpAsked === true && { wrapUpAsked: true }),
// The tools off after the evidence revision (an enforcing answer
// validation) — the same trip: written by the EvidenceRecheck branch
// on the OUTER scope, read by callLLM in here. Value-conditional, so
// every other boundary's args are the exact bytes they always were.
...(p.toolsOffAfterRevision === true && { toolsOffAfterRevision: true }),
// `.configure()` results, resolved + committed by seed on the OUTER
// chart. callLLM and the System Prompt slot both live in here, so
// the values have to cross the boundary. Read-only inside (nothing
// in the turn re-decides them), and the keys are omitted entirely
// for an agent without `.configure()`.
...(p.resolvedModel !== undefined && { resolvedModel: p.resolvedModel }),
...(p.resolvedInstructions !== undefined && {
resolvedInstructions: p.resolvedInstructions,
}),
...memoryKeys,
// Cross-iteration accumulators under prior* aliases — frozen
// here, copied to writable working keys by dynamicTurnSeed.
// `history` joined them in 7.21: the Deliver stage writes the
// window, so it can no longer arrive under its own name.
priorHistory: p.history,
priorDeliveredMessageKeys: p.deliveredMessageKeys,
priorTotalInputTokens: p.totalInputTokens,
priorTotalOutputTokens: p.totalOutputTokens,
...(p.totalCacheReadTokens !== undefined && {
priorTotalCacheReadTokens: p.totalCacheReadTokens,
}),
priorCumTokensInput: p.cumTokensInput,
priorCumTokensOutput: p.cumTokensOutput,
priorCumEstimatedUsd: p.cumEstimatedUsd,
priorCostBudgetHit: p.costBudgetHit,
priorSkillHistory: p.skillHistory,
};
},
outputMapper: (sf) => {
const s = sf;
return {
// LLM result the outer Route / tool-calls / final read.
llmLatestContent: s.llmLatestContent,
llmLatestToolCalls: s.llmLatestToolCalls,
...(deps.hasFindingsLedger === true && { findingsRequest: s.findingsRequest }),
thinkingBlocks: s.thinkingBlocks,
// Atomic tuple + Replace below: never merge a former layout or
// binding into a new provider proof. Present undefined clears it.
...(Object.hasOwn(s, 'llmLatestReplay') && { llmLatestReplay: s.llmLatestReplay }),
// Native output candidates only — absent otherwise, so the mapper
// is byte-identical for every other agent (see AgentState).
...(Object.hasOwn(s, 'llmLatestStopReason') && {
llmLatestStopReason: s.llmLatestStopReason,
}),
...(Object.hasOwn(s, 'llmLatestNativeOutput') && {
llmLatestNativeOutput: s.llmLatestNativeOutput,
}),
// The window this turn actually sent, back to the outer scope — so
// the next iteration's window stage and the ToolCalls stage extend
// the SAME past the model was given, not the one before delivery.
// `arrayMerge: Replace` below is what makes this an overwrite
// instead of a doubling concat.
history: s.history,
deliveredMessageKeys: s.deliveredMessageKeys,
...((deps.evidenceRecheckStage !== undefined ||
deps.negativeRecheckStage !== undefined) &&
s.evidenceRecoveryUsed !== undefined && {
evidenceRecoveryUsed: s.evidenceRecoveryUsed,
}),
// The delivery record travels with them. It is the committed answer
// to "why is my declaration not on the wire?", and its own docs send
// the reader to `snapshot.sharedState` — which is the OUTER scope. A
// record that only exists inside the subflow would answer the
// question everywhere except where it tells you to look.
messagesDelivery: s.messagesDelivery,
// The composed system prompt's records (9.35.0) — bubbled ONLY for
// an agent with the evidence gate mounted. The outer Route decider
// exempts values the APP supplied (base prompt, skill body, a
// retrieved passage) from the check, and in this chart shape the
// slot that produced them ran in here. Value-conditional, so every
// other agent's boundary mapper is byte-identical to what it was.
...(deps.hasEvidenceGate === true && {
systemPromptInjections: s.systemPromptInjections,
}),
// ── THE WIRE THE MODEL WAS HANDED, BUBBLED OUT (9.86.0) ────────
// This comment used to say the opposite, and it was true until the
// read_skill gate learned to answer a SELF-CALL by naming the tools
// that were genuinely on that call's list (9.84.0). That gate — the
// ToolCalls stage — runs OUT HERE, and `selfSkillTools` reads both
// of these: `dynamicToolSchemas` for the wire, `activeInjections`
// for the cursor's declaration. Inside the grouped chart they never
// crossed, so the notice fell back to "the wire is unknown" and said
// nothing about tools at all — silence that is honest and useless,
// and different from what the same agent says under 'dynamic'.
// Neither key is an input to this boundary (see the inputMapper), so
// bubbling them feeds nothing back in; `arrayMerge: Replace` below
// makes each an overwrite rather than a doubling concat.
dynamicToolSchemas: s.dynamicToolSchemas,
activeInjections: s.activeInjections,
// THE RECEIPT (9.88.0), second hop. `callLLM` writes it INSIDE this
// boundary, so under `'dynamic-grouped'` it lands in the turn's own
// inner log and nowhere else; a reader scrubbing the OUTER axis
// would find an iteration stop with no record of what that iteration
// sent. Bubbling costs nothing and never doubles: the receipt is a
// fresh object every call (the epoch is on it), so the net-change
// filter cannot drop it and `arrayMerge: Replace` below makes it an
// overwrite. Value-conditional, so a chart whose inner stage never
// wrote one crosses no new key.
...(s.receipt !== undefined && { receipt: s.receipt }),
// The role-hidden ids, second hop — the gate filters every set it
// names through them before composing a refusal or a payload.
...(s.hiddenSkillIds !== undefined && { hiddenSkillIds: s.hiddenSkillIds }),
// Accumulators bubbled back for the next iteration's inputMapper.
totalInputTokens: s.totalInputTokens,
totalOutputTokens: s.totalOutputTokens,
// Cache reads (9.110.0), bubbled back only once reported.
...(s.totalCacheReadTokens !== undefined && {
totalCacheReadTokens: s.totalCacheReadTokens,
}),
cumTokensInput: s.cumTokensInput,
cumTokensOutput: s.cumTokensOutput,
cumEstimatedUsd: s.cumEstimatedUsd,
costBudgetHit: s.costBudgetHit,
skillHistory: s.skillHistory,
// Advanced skill-graph cursor bubbled back for the next iteration
// (the inner injection engine wrote it under nextSkillCursor).
currentSkillId: s.nextSkillCursor,
// The re-keyed step pointer (9.18.0), back onto the outer key the
// tool-calls stage advances — the cursor's round trip, one line up.
// Top-level ARRAY + `arrayMerge: Replace` = set wholesale.
...(deps.hasSteps === true && { stepPointer: s.nextStepPointer }),
// The lease tenure sweep's survivors (9.19.0) — second hop, onto
// the outer key the tool-calls stage appends to. Permanent lease
// death (no cyclic resurrection); value-conditional, so agents
// that never saw a grant keep byte-identical mapper output.
...(s.nextInstructionLeases !== undefined && {
instructionLeases: s.nextInstructionLeases,
}),
// The advanced engagement state (9.58.0) — second hop of the alias
// round trip, onto the outer `mapEngagement` key. Value-conditional.
...(s.nextMapEngagement !== undefined && {
mapEngagement: s.nextMapEngagement,
}),
// The integrity seen-list's second hop (9.60.0). Value-conditional.
...(s.integrityFindingIds !== undefined && {
integrityFindingIds: s.integrityFindingIds,
}),
// Second hop, onto the outer key the next iteration reads.
...(s.nextServedInjectionIds !== undefined && {
servedInjectionIds: s.nextServedInjectionIds,
}),
// Second hop, onto the outer key the tools slot reads.
...(s.parkedToolNames !== undefined && {
parkedToolNames: s.parkedToolNames,
}),
// The classifier's rows (9.105.0), second hop — onto the outer key
// the next iteration's boundary reads. Top-level ARRAY +
// `arrayMerge: Replace` = set wholesale. Value-conditional.
...(s.toolChoices !== undefined && { toolChoices: s.toolChoices }),
// THE FAIL-FAST RECORD, out with the break below. `callLLM`'s
// reliability loop writes it in here and breaks; the run boundary
// reads it from the OUTER scope to raise `ReliabilityFailFastError`.
// Left inside, the run ended with `''` and no error. Present only
// after a fail-fast, so every other turn crosses no new key.
...(0, failFastRecord_js_1.failFastRecordOf)(s),
};
},
// llmLatestToolCalls / thinkingBlocks / skillHistory are arrays —
// REPLACE (not concat) so each turn overwrites the prior value.
arrayMerge: advanced_1.ArrayMergeMode.Replace,
// A break inside the turn ends the RUN, as it does in the flat chart,
// where `callLLM` sits on the run's own traversal. The one stage in here
// that breaks is the reliability loop's fail-fast exit; without this the
// break stopped only the turn and the outer loop went on to `Final`.
propagateBreak: true,
})
// Declared milestones (9.90.0): the mount is the iteration boundary on the
// OUTER log; the decider and its branches declare in their own `tags`.
.tag(...(0, conventions_js_1.milestoneTagsFor)(conventions_js_1.SUBFLOW_IDS.LLM_CALL));
// ── The inputs layer — conditional mount (honesty layer 2) ───────────
// The flat chart's twin, through the same helper: OUTSIDE `sf-llm-call`, at
// the same place — after the LLM call, before Route. Absent → untouched.
let decider = (0, mounts_js_1.mountInputsLayer)(withLlmCall, deps.inputsLayer)
.addDeciderFunction('Route', deps.routeDecider, conventions_js_1.SUBFLOW_IDS.ROUTE, 'ReAct routing', {
tags: (0, conventions_js_1.milestoneTagsFor)(conventions_js_1.SUBFLOW_IDS.ROUTE),
})
.addPausableFunctionBranch('tool-calls', 'ToolCalls', deps.toolCallsHandler, 'Tool execution (pausable via pauseHere)',
// Branch-sourced loop: tool-calls loops back to the LLM-call subflow so
// every iteration re-runs the full context-engineering + call against the
// freshest outer state. Sourced from the BRANCH (not the decider) so the
// chart reads honestly — `ToolCalls → LLM` 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: (0, conventions_js_1.milestoneTagsFor)('tool-calls') });
// ── The schema re-ask — conditional mount (7.26) ────────────────
// Byte-twin of the flat chart's mount, loop target and all: the second
// looping branch, going back to the same place the tool branch does, so a
// re-ask is one more ordinary turn through sf-llm-call. Absent unless the
// agent asked for retries.
if (deps.outputRetryStage) {
decider = decider.addFunctionBranch(conventions_js_1.STAGE_IDS.OUTPUT_RETRY, 'SchemaRetry', deps.outputRetryStage, 'Answer failed the output schema — put the correction back and ask again', { loopTo: loopTarget, tags: (0, conventions_js_1.milestoneTagsFor)(conventions_js_1.STAGE_IDS.OUTPUT_RETRY) });
}
// ── The unfinished-steps nudge — conditional mount (9.18.0) ─────────
// Byte-twin of the flat chart's mount: same loop target, same
// "one more ordinary turn" mechanism, absent without a stepped skill.
if (deps.stepNudgeStage) {
decider = decider.addFunctionBranch(conventions_js_1.STAGE_IDS.STEP_NUDGE, 'StepNudge', deps.stepNudgeStage, 'Answer left declared steps unrun — one teaching nudge goes back (once per turn)', { loopTo: loopTarget, tags: (0, conventions_js_1.milestoneTagsFor)(conventions_js_1.STAGE_IDS.STEP_NUDGE) });
}
// ── The evidence recheck — conditional mount (9.35.0) ───────────────
// Byte-twin of the flat chart's mount: same loop target, same "one more
// ordinary turn" mechanism, absent under `posture: 'assist'` and absent
// entirely without the gate.
if (deps.negativeRecheckStage) {
decider = decider.addFunctionBranch(conventions_js_1.STAGE_IDS.NEGATIVE_RECHECK, 'NegativeCitationCorrection', deps.negativeRecheckStage, 'Correct a citation once with tools disabled', { loopTo: loopTarget, tags: (0, conventions_js_1.milestoneTagsFor)(conventions_js_1.STAGE_IDS.NEGATIVE_RECHECK) });
}
if (deps.evidenceRecheckStage) {
decider = decider.addFunctionBranch(conventions_js_1.STAGE_IDS.EVIDENCE_RECHECK, 'EvidenceRecheck', deps.evidenceRecheckStage, 'Answer stated values no tool result carried — naming them back for one revision', { loopTo: loopTarget, tags: (0, conventions_js_1.milestoneTagsFor)(conventions_js_1.STAGE_IDS.EVIDENCE_RECHECK) });
}
// ── The out-of-budget wrap-up — conditional mount (9.56.0) ─────────
// Byte-twin of the flat chart's mount: same loop target, same "one more
// ordinary turn" mechanism, absent on an agent with no tool surface and on
// one that set `wrapUpAtMaxIterations: false`.
if (deps.wrapUpStage) {
decider = decider.addFunctionBranch(conventions_js_1.STAGE_IDS.WRAP_UP, 'WrapUp', deps.wrapUpStage, 'Action budget exhausted — one last call with the tools withheld, for a real answer', { loopTo: loopTarget, tags: (0, conventions_js_1.milestoneTagsFor)(conventions_js_1.STAGE_IDS.WRAP_UP) });
}
const chart = decider
.addSubFlowChartBranch(conventions_js_1.SUBFLOW_IDS.FINAL, finalBranchChart, 'Final', {
tags: (0, conventions_js_1.milestoneTagsFor)(conventions_js_1.SUBFLOW_IDS.FINAL),
inputMapper: (parent) => {
const { finalContent: _f, newMessages: _nm, ...rest } = parent;
void _f;
void _nm;
if (deps.hasAnswerValidation === true) {
const { answerValidationCommitted: _committed, ...input } = rest;
void _committed;
return input;
}
return rest;
},
outputMapper: (sf) => (0, breakFinal_js_1.finalBranchOutput)(sf, deps.hasAnswerValidation === true),
// `final` is a terminal LEAF under the branch-sourced loop; propagateBreak
// is kept for the terminal onBreak signal (observability), not loop control.
propagateBreak: true,
})
.setDefault(conventions_js_1.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 → LLM` for the loop edge.
return chart.build();
}
exports.buildDynamicAgentChart = buildDynamicAgentChart;
//# sourceMappingURL=buildDynamicAgentChart.js.map