UNPKG

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
"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