UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

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