agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
5,595 lines • 325 kB
JavaScript
import { findingsContextBytes } from './agent/findings/context.js';
/**
* Agent — ReAct primitive (LLM + tools + iteration loop).
*
* Pattern: Builder (GoF) → produces a Runner backed by a footprintjs FlowChart.
* Role: Layer-5 primitive (core/). Assembles the 3-slot context
* pipeline + callLLM + route decider + tool-calls subflow +
* loopTo. Composition nestable anywhere that accepts a Runner.
* Emits: Via internal recorders:
* agentfootprint.agent.turn_start / turn_end
* agentfootprint.agent.iteration_start / iteration_end
* agentfootprint.agent.route_decided
* agentfootprint.stream.llm_start / llm_end
* agentfootprint.stream.tool_start / tool_end
* agentfootprint.context.* (via ContextRecorder)
*/
import { FlowChartExecutor, } from 'footprintjs';
import { ReliabilityFailFastError } from '../reliability/types.js';
import { extractSequence } from '../security/extractSequence.js';
import { PolicyHaltError } from '../security/PolicyHaltError.js';
import { updateSkillHistory as updateSkillHistoryStage } from '../cache/CacheGateDecider.js';
import { cacheStrategyFor } from '../cache/cacheStrategyFor.js';
import { cacheRequestedWithoutSupport } from './agent/cacheSupport.js';
import { forcedToolChoiceFor, UnsupportedToolChoiceError, } from '../provider-contract/forcedToolChoice.js';
import { nativeOutputSchemaFor, UnsupportedNativeOutputError, } from '../provider-contract/nativeOutputSchema.js';
import { isRequestRefusal } from '../provider-contract/requestRefusal.js';
import { assistantReplayFields } from './agent/assistantReplay.js';
import { buildBrainFor, describeServingBrain } from './agent/skillBrains.js';
import { SUBFLOW_IDS } from '../lib/conventions.js';
import { readDisplayName } from '../lib/displayName.js';
import { DecisionRequiredError, ResumeDecisionError, assertDecisionIsNotStale, isPaused, pauseDemandsDecision, } from './pause.js';
import { checkDecisionAgainst, noteDecisionTakenUp, pausedRequestIdOf, processDecisionLedger, registerConsentLedger, registerConsentRules, } from './consentRequest.js';
import { assertArtifactPlacement } from '../artifacts/placement.js';
import { recordingPutInput } from '../artifacts/recordingArtifact.js';
import { recordRunWhere } from '../recorders/observability/recordRun.js';
import { CredentialConsentRequiredError } from '../identity/CredentialConsentRequiredError.js';
import { ContextRecorder } from '../recorders/core/ContextRecorder.js';
import { contextEvaluatedRecorder } from '../recorders/core/ContextEvaluatedRecorder.js';
import { streamRecorder } from '../recorders/core/StreamRecorder.js';
import { agentRecorder } from '../recorders/core/AgentRecorder.js';
import { errorBridge } from '../recorders/core/ErrorBridge.js';
import { costRecorder } from '../recorders/core/CostRecorder.js';
import { permissionRecorder } from '../recorders/core/PermissionRecorder.js';
import { evalRecorder } from '../recorders/core/EvalRecorder.js';
import { memoryRecorder } from '../recorders/core/MemoryRecorder.js';
import { embeddingRecorder } from '../recorders/core/EmbeddingRecorder.js';
import { skillRecorder } from '../recorders/core/SkillRecorder.js';
import { validationRecorder } from '../recorders/core/ValidationRecorder.js';
import { credentialRecorder } from '../recorders/core/CredentialRecorder.js';
import { mapRecorder } from '../recorders/core/MapRecorder.js';
import { integrityRecorder } from '../recorders/core/IntegrityRecorder.js';
import { artifactsRecorder } from '../recorders/core/ArtifactsRecorder.js';
import { toolsRecorder } from '../recorders/core/ToolsRecorder.js';
import { reliabilityRecorder } from '../recorders/core/ReliabilityRecorder.js';
import { resilienceRecorder } from '../recorders/core/ResilienceRecorder.js';
import { checkInEventsBridge } from '../recorders/core/CheckInRecorder.js';
import { compactionMeter } from '../recorders/core/CompactionMeter.js';
import { assertRedactionPolicy, CarriedPolicyError, carriedVocabularyOf, policyFromCarried, policyOfMarks, unionRedactionPolicies, } from '../redaction/policy.js';
import { coverageOfExecutor } from '../redaction/runRedaction.js';
import { policyOfCoverage } from '../redaction/coverage.js';
import { ResumeRedactionError } from './conversation.js';
import { declareRedaction, redactionDeclaredBy } from '../redaction/declared.js';
import { carriedRunPolicy, conversationRedaction, withCurrentVocabulary, } from '../redaction/conversation.js';
import { createEvictedTurnsHandle } from './agent/window/evictedTurns.js';
import { createReceiptDigests } from './agent/receipt/receiptDigests.js';
import { packRecording } from '../recorders/observability/recordingPack.js';
import { pendingDurableWrite } from './durabilityBarrier.js';
import { ToolSessionTier, TOOL_TEARDOWN_TIMEOUT_MS, } from '../tools/toolSessions.js';
import { buildEventMeta, eventBelongsToRun } from '../bridge/eventMeta.js';
import { assertIdentityShape, callerIdentityOf, pausedSessionOf, restoredIdentityOf, sameIdentity, } from './agent/callerIdentity.js';
import { hostedConversationKey, hostedConversationOf, registerServingConversation, sessionConversationKey, } from './agent/servingConversation.js';
import { buildRunManifest } from './agent/runManifest.js';
import { beginIntegrityRun } from '../integrity/disposition/lifecycle.js';
/**
* The pseudo-stage a tool-teardown event is stamped with.
*
* `session_closed` and `session_close_failed` fire after the run's last stage
* has committed — there is no runtimeStageId to inherit, and inventing one that
* looked like a real stage would put a fictitious node in every consumer's step
* strip. This says plainly where it came from, the way `'<stageId>#paused'`
* does at the pause boundary. Their two siblings, `session_started` and
* `session_reused`, happen inside a real stage and carry its real id.
*/
const TOOL_TEARDOWN_STAGE_ID = 'tool-teardown#0';
/**
* The pseudo-stage a LATE artifact fact is stamped with — one that lands after
* the run its binding was made in had ended (a tool's floating upload). Its
* stage is long gone; like the teardown events it says plainly where it came
* from, and carries the run it belongs to rather than the one that is live.
*/
const LATE_ARTIFACT_FACT_STAGE_ID = 'artifact-late#0';
/**
* The pseudo-stage the run-configuration manifest is stamped with (9.41.0).
*
* Same reasoning as the teardown id above, at the other end of the run: the
* manifest is dispatched before the chart starts, so there is no stage to
* inherit and inventing a plausible one would put a node in every step strip
* that no traversal ever visited. This says plainly where it came from.
*/
const RUN_MANIFEST_STAGE_ID = 'run-configured#0';
/**
* Pseudo-stage for `agentfootprint.integrity.disposition` (9.60.0) — the
* ledger is filed at the run boundary, after the last stage committed, so it
* states where it came from exactly as the teardown id above does.
*/
const INTEGRITY_DISPOSITION_STAGE_ID = 'integrity-disposition#0';
/**
* Pseudo-stage for `agentfootprint.skill.graph_declared` (9.50.0) — same
* reasoning as the manifest id directly above: dispatched before the chart
* starts, so it states where it came from instead of inheriting a stage no
* traversal visited.
*/
const GRAPH_DECLARED_STAGE_ID = 'graph-declared#0';
import { EmitBridge } from '../recorders/core/EmitBridge.js';
import { buildWindowStage } from './agent/stages/window.js';
import { requireKeepLastToolResults } from './agent/window/options.js';
import { assertDeliverableRole, buildDeliverStage } from './agent/stages/deliver.js';
import { providerTargets } from './agent/providerTargets.js';
import { messagesContentRefusal } from '../injection-engine/messagesSlotRefusal.js';
import { CompactionUnmeasurableError } from './agent/window/errors.js';
import { resolveCheckInConfig, } from './checkin.js';
import { assertCostBudgetHasPricing, resolveCostBudget } from './cost.js';
import { causalEvidenceRecorder, } from '../memory/causal/evidenceRecorder.js';
import { buildSystemPromptSlot } from './slots/buildSystemPromptSlot.js';
import { buildMessagesSlot } from './slots/buildMessagesSlot.js';
import { buildToolsSlot, } from './slots/buildToolsSlot.js';
import { isDevMode } from 'footprintjs';
import { buildReadSkillTool } from '../injection-engine/skillTools.js';
import { foldStepPlans } from '../injection-engine/skillSteps.js';
import { buildStepNudgeStage } from './agent/stages/stepNudge.js';
import { buildEvidenceRecheckStage } from './agent/stages/evidenceRecheck.js';
import { toolWantsOf } from './agent/stagedRefs.js';
import { wrapUpStage } from './agent/stages/wrapUp.js';
import { failFastRecordOf } from '../reliability/failFastRecord.js';
import { terminalErrorOf } from './terminalVerdict.js';
import { isValidatedDelivery } from './agent/stages/breakFinal.js';
import { evidenceRefusalSentence } from './agent/evidence/gate.js';
import { UnsupportedValuesError } from './agent/evidence/errors.js';
import { checkerGoverns } from '../provider-contract/types.js';
import { skillTarget } from '../security/skillTarget.js';
import { buildInjectionEngineSubflow } from '../injection-engine/buildInjectionEngineSubflow.js';
import { makePickEntryStage } from './agent/stages/pickEntry.js';
import { makeRouteTurnStage } from './agent/stages/routeTurn.js';
import { applyOutputFallback, } from './outputFallback.js';
import { assertContinuable, buildCheckpoint, checkpointReplayHistory, retainReplayRetirements, classifyFailurePhase, RunCheckpointError, validateCheckpoint, } from './runCheckpoint.js';
import { requireVerificationOnResume } from './agent/findings/verificationCheckpoint.js';
import { NoConversationError, PendingQuestionError, ResumeIdentityConflictError, RunInFlightError, } from './conversation.js';
import { applyInputResponse, readAwaitingInput, } from '../tools/inputRequest.js';
import { applyOutputSchema, OutputSchemaError, } from '../answer-validation/outputSchema.js';
import { InvalidRunInputError, normalizeRunInput } from './runInput.js';
import { clockOf } from '../time/rows.js';
import { draftClock, readRunTime, readTimeOptions, } from '../time/clock.js';
import { buildOutputRetryStage } from './agent/stages/outputRetry.js';
import { RunnerBase, makeRunId, runSignalOf } from './RunnerBase.js';
import { clampIterations, validateMemoryIdUniqueness, validateToolNameUniqueness, } from './agent/validators.js';
import { buildRouteDeciderStage } from './agent/stages/route.js';
import { negativeCheckpointOf } from './agent/negativeClaims/checkpoint.js';
import { withNegativeProse } from './agent/negativeClaims/observe.js';
import { negativeRecheckStage } from './agent/negativeClaims/recheck.js';
import { withAnswerValidation } from './agent/stages/answerValidation.js';
import { hasOutputMiddleware } from './agent/middleware/messagePhase.js';
import { withAnswerCoverage } from './agent/stages/answerCoverage.js';
import { AnswerValidationError, resolveAnswerValidation, } from '../answer-validation/index.js';
import { buildSeedStage } from './agent/stages/seed.js';
import { buildCallLLMStage } from './agent/stages/callLLM.js';
import { buildToolCallsHandler, notServedResult } from './agent/stages/toolCalls.js';
import { buildToolResolver } from './agent/stages/toolResolver.js';
import { answeredValuesOf, declaredDefaultsFrom } from './agent/stages/route.js';
import { isRefused, rulesOf } from '../tools/declare.js';
import { honestyLayersOf, readInputsLayerOption, } from './agent/honesty/armed.js';
import { answerFoldReads } from './agent/honesty/mounts.js';
import { readsMessageFrom } from './messageFrom.js';
import { memberIdentityIn, memberRequestIn, readsParentRun } from './memberRun.js';
import { requestOf } from './requestOfRun.js';
import { carriesArgumentView } from './toolShownArgs.js';
import { assertMaxToolResultChars } from './agent/toolResultCap.js';
import { buildAgentChart } from './agent/buildAgentChart.js';
import { buildDynamicAgentChart } from './agent/buildDynamicAgentChart.js';
import { buildToolRegistry } from './agent/buildToolRegistry.js';
import { AgentBuilder } from './agent/AgentBuilder.js';
import { buildThinkingSubflow } from './slots/buildThinkingSubflow.js';
import { thinkingForProviders } from '../thinking/dispatch.js';
export { AgentBuilder };
// Public types (AgentOptions, AgentInput, AgentOutput) extracted to
// ./agent/types.ts and re-exported above (v2.11.1).
// AgentState extracted to ./agent/types.ts (v2.11.1).
/**
* The ledger-fact hold's default ceiling (9.102.0): how many fact turns the
* window holds beyond `keepRecentTurns` when `.findings()` and a window
* strategy are both configured and nobody named a number. Resolved in the
* constructor, ONCE, and threaded to `buildWindowStage` as a plain number —
* the stage applies no default of its own (absent = unarmed = no hold), so a
* reader has one place to look. `window/options.ts ·
* DEFAULT_KEEP_LAST_TOOL_RESULTS` is the pin's twin of this constant.
*/
const DEFAULT_KEEP_LEDGER_FACTS = 4;
/**
* Validate the `keepLedgerFacts` dial — `window/options.ts ·
* requireKeepLastToolResults`'s twin: the same rule, the same moment.
* Refused at construction, never mid-run.
*/
function requireKeepLedgerFacts(value, label) {
if (value === false)
return;
if (!Number.isInteger(value) || value < 0) {
throw new Error(`${label}: keepLedgerFacts must be a whole number >= 0, or false, got ` +
`${String(value)}. It is how many fact turns the window holds beyond ` +
`keepRecentTurns under .findings(); 0 and false both switch the hold off.`);
}
}
/** `false` → `0`; a number as named; nothing named → the default. */
function resolveKeepLedgerFacts(named) {
if (named === false)
return 0;
return named ?? DEFAULT_KEEP_LEDGER_FACTS;
}
export class Agent extends RunnerBase {
name;
id;
provider;
model;
temperature;
maxTokens;
maxIterations;
systemPromptValue;
/**
* Cache policy for the base system prompt (set via
* `.system(text, { cache })`). Default `'always'` — base prompt is
* stable per-turn, ideal cache anchor. CacheDecision subflow reads
* this when computing the SystemPrompt slot's cache markers.
*/
systemPromptCachePolicy;
/**
* Global cache kill switch from `Agent.create({ caching: 'off' })`.
* Threaded into agent scope at seed-time as `scope.cachingDisabled`;
* read by the CacheGate decider every iteration (highest-priority rule).
*/
cachingDisabledValue;
/**
* The CacheStrategy this agent runs. Chosen at build time from the
* provider's declared `promptCaching` (`cacheStrategyFor`) unless the
* consumer passes one explicitly.
*/
cacheStrategy;
cacheStrategyIsOverride;
cacheUnsupported;
registry;
/**
* The Injection list — Skills, Steering, Instructions, Facts (and
* RAG, Memory). Evaluated each iteration by the
* InjectionEngine subflow; active set is filtered by slot subflows.
*/
injections;
/** Skill-graph cursor resolver (`graph.nextSkill`), set when built via
* `.skillGraph(graph)`. Plumbed into the Injection Engine so route triggers
* are `from`-gated against the persisted `currentSkillId`. */
skillGraphNextSkill;
/** Skill-graph reachable-set resolver (`graph.reachableSkills`), set when built
* via `.skillGraph(graph)`. Plumbed into the tool-calls handler so `read_skill`
* is gated to in-graph jumps. Undefined → gate off. */
skillGraphReachable;
/** Skill-graph relevance entry scorer (`graph.scoreEntries`), set when built via
* `.skillGraph(graph)` with `.entryByRelevance()`. Drives the PickEntry stage.
* Undefined → no relevance entry routing (cold-start entry as before). */
skillGraphScoreEntries;
/** The `to` end of every edge the mounted graph declares — which skills the graph
* WIRES. Empty for a graph-less agent. Read only by `openSkillIds()`. */
skillGraphEdgeTargets;
/** Skill-graph cursor resolver that also reports WHICH CLAUSE won
* (`graph.explainNextSkill`, 8.5.0). Threaded to the Injection Engine, which
* stamps the result on `context.evaluated` as `cursorMove`. Optional — a graph
* built before it existed falls back to `nextSkill` and emits no `cursorMove`. */
skillGraphExplainNextSkill;
/** Skill-graph suppression reporter (`graph.supersededEntries`, 8.15.0) — the
* conditional entries whose `when` matched while the cursor was elsewhere.
* Threaded to the Injection Engine, which stamps them on `context.evaluated` as
* `supersededIds`. Optional — a graph built before it existed routes identically
* and simply emits no `supersededIds`. */
skillGraphSupersededEntries;
/** Is the mounted graph a decision `tree()`? Derived at build time from
* `graph.nodes` (a tree is the only shape with `predicate` nodes) — no new
* public field on `SkillGraph`. Read for ONE thing: the read_skill gate's
* refusal has to explain that a tree has no cursor to jump (8.5.0). */
skillGraphIsTree;
/** The turn-start cascade wiring (SG-C, 9.17.0) — the graph's routing plan,
* the mount's posture/continuity, and the node-id set droppedResume checks
* against. Absent (every graph without the new options) → RouteTurn never
* mounts, the gate never postures, seed never restores a cursor, and the
* checkpoint shape is byte-identical. */
/** The folded per-skill brains + escalation + decider (9.19.0) —
* validated at `AgentBuilder.build()`. Undefined = no brain anywhere:
* callLLM, the gate, seed and RouteTurn wire nothing new. */
skillBrains;
/**
* The evidence gate (9.35.0) — `.namesAndNumbersFromEvidence()`, resolved by
* the builder. Undefined for every agent that did not ask for it, and that
* undefined is the whole zero-cost guarantee: no decider change, no branch,
* no scope key, no event.
*/
evidenceGate;
/**
* `.limitsTravelWithTheAnswer()` — whether the run's declared coverage
* travels with the final answer: appended as a block to a prose answer, or,
* when an output schema makes the answer TYPED, committed beside it as data
* (`answerCoverage()`), because JSON followed by prose is not JSON. False for
* every agent that did not ask for it; the RECORDING half of the coverage
* primitives does not consult it.
*/
limitsTravelWithTheAnswerValue = false;
/**
* `.scriptTravelsWithTheAnswer()` — the resolved arm, or undefined for every
* agent that did not ask. With `code: 'match'` on the gate it arms the
* script record (`scriptRecordArmed`); alone it also picks the final stage.
*/
scriptTravel;
skillGraphCascade;
/** Side channel for the conversation's inherited skill cursor (SG-C) —
* stashed by `applyContinuation`, consumed-and-cleared by seed. */
pendingResumeSkillCursor;
pricingTable;
/** Normalized at construction: a bare number is `{ usd, onExceed: 'warn' }`. */
costBudget;
/** Per-slot character budgets (8.11.0). Absent keys keep the slot default. */
contextBudget;
permissionChecker;
toolArgValidation;
/** See AgentOptions.findings (9.101.0). Set by `.findings()` and undefined
* on every other agent — the one value every findings gate below is
* conditioned on, so an unarmed agent hands each stage exactly the deps
* it always did. `serve` is threaded to seed (the run constant
* `findingsServe`) and to call-llm (`findingsServe` in its deps) on an
* armed agent only; `answerAsk` (9.103.0) rides the same two threads
* as `findingsAnswerAsk`, and only when it is `'quote-facts'` — the
* default `'none'` threads nothing. `keepLedgerFacts` is resolved once, here in the
* constructor (this door over `AgentOptions.keepLedgerFacts`), into the
* `keepLedgerFacts` field below and threaded to the window stage on an
* armed agent with a window, where `stages/window.ts · buildWindowStage`
* spends it as the `'ledger-fact'` pin ceiling. */
findingsOptions;
/** `.inputsLayer()` (honesty layer 2): mount the inputs layer even when no
* registered tool declares `askOrAssume` — for ruled tools only a
* ToolProvider serves, which the build cannot see. */
inputsLayerOption;
/** DECLARED SOURCES (honesty layer 2): `.findings({ argumentSources: true })`
* or `.inputsLayer({ argumentSources: true })` — resolved ONCE, in the
* constructor, from either door; every declared-sources gate below reads
* this one value, so the two doors can never arm different halves. The
* findings ledger's own serve stays `findingsOptions`'s: without
* `.findings()` the arm plants `_findings.from` on ruled tools only. */
argumentSourcesArmed;
/** `AgentOptions.argumentAskContext` (honesty layer 2): the host's own
* context for the inputs layer's batch ask, handed to the dispatch stage
* only when the layer is mounted. */
argumentAskContext;
/** Whether this agent's chart mounts the inputs layer (`sf-inputs`) —
* decided ONCE, in `buildChart`: a registered tool declares rules, or
* `.inputsLayer()` was set. Gates the findings event bridge and the
* widened ledger restore. */
inputsLayerArmed = false;
/** `.resultsLayer()` (honesty layer 3, step 7b): mount the results layer
* even when no registered tool declares a `ToolPeriod` — for tools a
* ToolProvider serves, and for tools that declare a period only on their
* results. */
resultsLayerOption;
/** Whether this agent's chart mounts the results layer (`sf-results`) —
* decided ONCE, in `buildChart`: a registered tool declares a `ToolPeriod`,
* or `.resultsLayer()` was set. Gates the findings event bridge and the
* widened ledger restore, as the inputs layer's flag does. */
resultsLayerArmed = false;
/** `.answerLayer()` (honesty layer 4): the answer's standing folded at the
* head of the final branch and served as data; `standingLine` — also one
* line appended to a prose answer, for the standings its mode names
* (`'always'` for `standingLine: true`). Undefined on every agent that did not
* ask, and that undefined is the whole zero-cost guarantee: no stage, no
* witness row, no stamp, no event, no bridge. */
answerLayerOption;
/** `.time()` (the time layer, `time/`): the fallback zone, as read.
* Undefined on every agent that did not ask — then no clock is read, no
* time row is filed and `time` on a run is refused. */
timeOptions;
/** The clock the CURRENT `run()`'s seed stamps — set by `run()` (a run with
* no zone gets an unknown one, never the server's), read by seed through `SeedStageDeps.timeClock`, and
* cleared when `run()` ends, so a later turn this agent did not start (its
* chart mounted in a composition) never stamps a clock nobody declared for
* it (`seedClockDraft`). */
runClockDraft;
/** The incoming request each of this agent's OWN scopes serves — recorded
* by the executor that made the scope (`createExecutor`), so a scope keeps
* ITS run's request for as long as it lives: a stage still running after
* its run was aborted can never be served the next run's request. A stage
* mounted in another runner's executor reads its run's request by link
* (`memberRun.ts`). Never written to scope. */
scopeRequests = new WeakMap();
/** The `time` the CURRENT resume passed, as read — TAKEN once by the first
* ToolCalls pass of the resumed leg (`ToolCallsHandlerDeps.time`), and
* cleared when `run()` or `resume()` ends. */
resumePassedTime;
/** Tool choice by classifier (9.105.0, `.toolChoice()`): the classifier,
* the serve dial and the app's own doors. Threaded to the tools slot (the
* pick and the narrowing), to call-llm (`toolChoice: true`, the outcome
* row) and to both chart builders (`hasToolChoice`, the mount args) on an
* armed agent only — an unarmed agent hands each stage exactly the deps it
* always did. */
toolChoiceOptions;
/** The declared ontology (9.106.0, `.ontology()`): the frozen map. Threaded
* to seed (the whole map, written once as the run constant `ontology`),
* to call-llm (`ontology: true`, the gate on every read of that key) and
* to both chart builders (`hasOntology`, the grouped boundary's crossing)
* on an armed agent only — an unarmed agent hands each stage exactly the
* deps it always did. The tool names its `via` edges name are checked
* against the registry in `buildChart`, beside `buildToolRegistry`. */
ontology;
/** The map's tool → declaring-skills join (9.108.0), read off the registry
* in `buildChart` beside the `via` check; `undefined` when no `via` name
* is a skill's. Seed reads it through a thunk (the stage is built first). */
ontologyTools;
/** The opt-in tool-result ceiling in characters (9.11.0). Absent → results
* are never measured. See {@link AgentOptions.maxToolResultChars}. */
maxToolResultChars;
/** Resolved check-in config (evidence-carrying human consent). Always
* present — defaults to `standard` evidence + the lexical scorer, so a tool
* that declares `checkIn` works even without a `.checkIn()` builder call. */
checkInConfig;
/** Where answered consent requests are spent (`AgentOptions.decisionLedger`).
* Undefined → this process's ledger, shared by every agent that names none. */
decisionLedger;
/** Per-run config resolver from `.configure()`. Undefined for every agent
* that never called it — and the chart is then built exactly as before,
* with no scope writes and no scope reads added. */
runConfigFn;
/** The agent's one window strategy. Undefined = no window stage, loop
* target unchanged, run byte-identical to an agent without it. */
windowStrategy;
/** The tool-dispatch chain (`.toolMiddleware()`), in declaration order.
* Empty for every agent that never called it — and then the dispatch loop
* never walks a chain, never writes the ledger key, and never emits. */
toolMiddleware;
/** The message chain (`.messageMiddleware()`), in declaration order. Empty
* keeps seed synchronous and prepare-final untouched. */
messageMiddleware;
/** The instrument the window stage reads mid-run (adapter-reported usage +
* per-message provenance). Only ever created alongside a strategy. */
compactionMeterHandle;
/** The window stage's seam to the receipt (9.93.0): what left the window at
* this iteration's head, read by the call-llm mint in the same iteration.
* Only ever created alongside a strategy, like the meter. */
evictedTurnsHandle;
/** Snapshot read-tracking policy (#18/#14) — forwarded to the internal
* executor. Agent default is `'summary'` (cheap markers), NOT
* footprintjs's `'full'`. See AgentOptions.readTracking. */
readTracking;
/** Commit-log value encoding (#13c-B) — forwarded to the internal
* executor. Agent default is `'delta'` (append/delete verbs; growing
* arrays like `history` record only their tails — lossless, linear
* retained memory), NOT footprintjs's `'full'`. See
* AgentOptions.commitValues. */
commitValues;
/** Per-write read provenance (#P1) — forwarded to the internal executor.
* Default `'off'` (footprintjs's own): recordings stay byte-identical
* unless a consumer opts in. See AgentOptions.writeProvenance. */
writeProvenance;
credentialProvider;
/** The claim-check store (9.21.0). When set, every tool's `ctx.artifacts`
* is this store bound to the run's scope. See AgentOptions.artifacts. */
artifactStore;
/** The placement threshold (9.22.0) — the operator's ref-ing dial from the
* object form of AgentOptions.artifacts. Only ever set beside a store. */
artifactPlacement;
/** Recordings-as-artifacts (9.26.0) — the operator's dial from the object
* form of AgentOptions.artifacts. Only ever set beside a store; absent
* means no recorder is ever attached and no run is ever recorded. */
artifactRecordings;
/** The repeated-call nudge (9.26.0) — `false` only when the operator turned
* it off. See AgentOptions.repeatedCallNudge. */
repeatedCallNudge;
/** The out-of-budget wrap-up (9.56.0) — `false` only when the operator
* turned it off. See AgentOptions.wrapUpAtMaxIterations. */
wrapUpAtMaxIterations;
/** The last-tool-result pin (9.57.0) — set only when the operator named a
* value other than the default 2. See AgentOptions.keepLastToolResults. */
keepLastToolResults;
/** The ledger-fact hold's ceiling (9.102.0), RESOLVED — set exactly when
* `.findings()` is on: `findings({ keepLedgerFacts })` over
* `AgentOptions.keepLedgerFacts`, `false` → `0`, nothing named →
* `DEFAULT_KEEP_LEDGER_FACTS`. Undefined on every unarmed agent, so the
* thread into the window stage reads as the decision it is. See
* AgentOptions.keepLedgerFacts. */
keepLedgerFacts;
/** See AgentOptions.integrityPosture (9.60.0). Default 'observe'. */
integrityPosture = 'observe';
/**
* The per-run disposition ledger, shared with the check sites by
* REFERENCE through build-time closures (the ProviderToolCache pattern —
* plumbing, never scope state). `run()` resets it; the run boundary
* files its report and clears it.
*/
integrityLedgerHolder = {
current: undefined,
};
/** Set at chart build: whether any tool in the FULL declared catalog
* (static registry + skill-carried tools) declared `argumentsFrom`. */
integrityDanglingPresent = false;
/** See AgentOptions.noticeEmptyLookups (9.77.0). Default false — absent is
* byte-identical, save for the registered not-applicable ledger row. */
noticeEmptyLookups = false;
/** See AgentOptions.noticePriorTurnEvidence (9.83.0). Default false —
* absent is byte-identical, save for the registered not-applicable ledger
* row. */
noticePriorTurnEvidence = false;
/** Set at chart build: whether any tool in the FULL declared catalog
* declared `resultColumns` (9.78.0) — the other half of the column-type
* contract's arming. */
integrityColumnsPresent = false;
/** See AgentOptions.checkColumnTypes (9.78.0). Default 'off' — absent is
* byte-identical, save for the registered not-applicable ledger rows. */
checkColumnTypes = 'off';
/** See AgentOptions.externalGrounds (9.72.0). Absent = door closed,
* byte-identical behavior. */
externalGrounds;
/** What a run does when a declared credential needs 3LO consent (8.6.0).
* Default `'pause'`. See AgentOptions.onAuthorizationRequired. */
onAuthorizationRequired;
/**
* Consent blocks outstanding in THIS run, keyed by service — the
* `'tell-model'` honesty ledger, and the only place the authorization URL
* lives between the block and the caller.
*
* It is a plain instance field rather than a scope key on purpose: tracked
* state is the commit log, which is the snapshot, the narrative and every
* recording, and this value is a bearer capability. Cleared at the top of
* every `run()` / `resume()` so one run can never raise on another's block,
* and cleared per service the moment that credential is issued.
*/
consentOutstanding = new Map();
/** Evidence bridge (#5) — present iff a CAUSAL memory is mounted. */
causalEvidence;
/** Observer delivery tier (RFC-001 Block 10). `'inline'` (default) is
* byte-identical to pre-10 releases; `'deferred'` routes the bridge
* recorders + consumer attachments through footprintjs's bounded
* capture queue. See AgentOptions.observerDelivery. */
observerDelivery;
/** Queue dials forwarded on every deferred attach (first attach
* configures the executor's single dispatcher). */
observerDeliveryOptions;
/**
* Voice config — shared by viewers (Lens, ChatThinkKit, CLI tail).
* `appName` is the active actor in narration ("Chatbot called…").
* `commentaryTemplates` drives Lens's third-person panel.
* `thinkingTemplates` drives chat-bubble first-person status.
* Defaults to bundled English; consumer overrides via builder.
*/
appName;
commentaryTemplates;
thinkingTemplates;
currentRunContext = {
runStartMs: 0,
runId: 'pending',
compositionPath: [],
};
/**
* The typed ask the CURRENT run's resume answered — set by
* `resume()` when a person's answer completed an `awaitingInput`, cleared
* by every other run start, so a pooled agent never marks one session's
* ask as another's re-ask. Read by the dispatch door through
* `ToolCallsHandlerDeps.answeredAsk`. The count crosses checkpoints on the
* awaiting-input shape itself (`repeat.count`), not here.
*/
answeredAsk;
// `lastExecutor` is now inherited as a protected field from RunnerBase
// (single canonical source for footprintjs snapshot access across all
// runners). Agent's `getLastSnapshot()` delegates to the inherited
// implementation but is kept here for the JSDoc + clearer return type.
// The chart is now cached on RunnerBase (`protected chart`) via
// `initChart()` — built ONCE at constructor time. `getSpec()` returns
// it. `createExecutor()` reuses it. The earlier `lastFlowChart`
// field was a per-run workaround for the lazy-build pattern; both
// are obsolete now. `getSpec()` always returns the same reference
// the executor traces.
/**
* Memory subsystems registered via `.memory()`. Each definition mounts
* its `read` subflow before the InjectionEngine on every turn; per-id
* scope keys (`memoryInjectionKey(id)`) keep multi-memory layering
* collision-free.
*/
memories;
/**
* Optional terminal contract. Set via the builder's `.outputSchema()`.
* When present, `agent.runTyped()` parses + validates the final
* answer against this parser. `agent.run()` keeps returning the
* raw string; consumers opt into typed mode explicitly.
*/
outputSchemaParser;
/**
* What the LOOP enforces about the output (7.26) — resolved by the builder
* when `.outputSchema()` was given `retries` or a `'tool-forced'` strategy.
* Undefined otherwise, and undefined is the whole of the byte-identical
* path: no branch is mounted, the decider is the function it always was,
* and the request is the one 7.25 sent.
*/
outputEnforcement;
/**
* Optional 3-tier degradation for output-schema validation
* failures. Set via the builder's `.outputFallback({...})`. When
* present, `parseOutput()` and `runTyped()` fall through:
* primary → fallback → canned (in order; canned guarantees no-throw).
*/
outputFallbackCfg;
/** Side-channel for `run({ continueFrom })` and `resumeOnError(...)` —
* when set, the seed function restores `scope.history` from this
* instead of starting fresh, and appends this turn's user entry when
* `appendsUserTurn` says so. Cleared on first read so subsequent runs
* start clean. */
pendingResumeHistory;
/** Retried request's repair state; never inherited by a new human turn. */
pendingEvidenceRecovery;
/** Its sibling for the folded spans. A restored conversation that dropped
* them would carry summaries nobody could unpack — the evidence would be
* destroyed by the act of continuing, which is the one thing retention
* exists to prevent. Cleared on first read, exactly like the history. */
pendingResumeFolded;
/** Its sibling for the findings ledger (9.101.0) — the model's standings
* on results this process never saw, carried by the checkpoint and
* restored as a stored record. Cleared on first read, like the history. */
pendingResumeFindingsLedger;
/** The last completed run's final answer — see `checkpoint()` for why it is
* kept here rather than read back from the recording. Undefined after a run
* that failed or paused. */
lastRunAnswer;
/** Last run's checkpoint owner, including explicit replay edits from a failed child stage. */
checkpointTracker;
/** Whether any run on this instance got past input admission — so a
* refused FIRST turn is reported as 'never-run', never as "continue from
* your earlier accepted checkpoint" when there is none. */
hadAdmittedRun = false;
/** The id the CONSUMER chose, or undefined when they took the default.
* `this.id` cannot answer that question — it is `'agent'` either way — and
* the stored-conversation fingerprint refuses only on ids somebody picked
* (see `AgentRunCheckpoint.agent`). */
explicitId;
/** The identity the caller gave the last run, or undefined when they gave
* none. Only an EXPLICIT identity is carried onto `checkpoint()`: the
* default is derived from a runId, and storing that would pin a whole
* conversation to the id of the one run that started it.
*
* Written by BOTH run doors, before the executor exists: `run()` from the
* call and the continued conversation, `resume()` from the call and the
* paused run's own record (`agent/callerIdentity.ts · callerIdentityOf`).
* A resume that inherited it instead stored another person's identity on
* a shared agent and none on a rebuilt pooled one. */
lastRunIdentity;
/** Run ids this instance minted, most recent last (bounded) — evidence that a
* checkpoint's `{ conversationId: '<runId>' }` is the per-run default
* (`agent/callerIdentity.ts`). */
mintedRunIds = new Set();
/** The self-explain evidence key of the run in flight (or served last):
* its session, else a host's per-request key, else undefined
* (`agent/servingConversation.ts`). */
servingConversation;
/** How long ONE tool teardown may take before the runner stops waiting.
* See `AgentOptions.toolTeardownTimeoutMs`. */
toolTeardownTimeoutMs;
/** The run in flight, by id — the whole of the one-turn-at-a-time guard.
* Set before the executor is built and cleared in `finally`, so a run that
* throws does not leave the agent permanently refusing. */
inFlightRunId;
/** The scopes this agent's OWN executors made — see the `isOwnRun` seed dep. */
ownScopes = new WeakSet();
/** The question a person still owes this agent an answer to. Set when a run
* ends paused, cleared by `resume()`, `abandonPause()`, or a run that
* completes. Read by the `run()` guard — see `PendingQuestionError`. */
pendingQuestion;
/** The `.selfExplain()` binding, when the builder mounted one. Held so
* `canExplain()` can answer the same question the trace tools answer, from
* the same fact. Undefined on every agent that never called `.selfExplain()`. */
selfExplainBinding;
/**
* Optional `ToolProvider` set via the builder's `.toolProvider()`.
* When present, the Tools slot subflow consults it per iteration
* (Block A5 follow-up) — the provider's tools land alongside any
* tools registered statically via `.tool()` / `.tools()`. The
* tool-call dispatcher also consults it for per-iteration execute
* lookup so dynamic chains (`gatedTools`, `skillScopedTools`)
* dispatch correctly when their visible-set changes mid-turn.
*/
externalToolProvider;
/**
* Optional rules-based reliability config (v2.11.5+). Set via the
* builder's `.reliability({...})`. When present, every CallLLM
* execution is wrapped in a retry/fallback/fail-fast loop driven
* by `preCheck` and `postDecide` rules. Consumed by `buildCallLLMStage`.
*/
reliabilityConfig;
/**
* Resolved ThinkingHandler (v2.14+). The provider's DECLARED one
* (`thinkingHandlerFor`) UNLESS the builder explicitly set one
* (or `null` to opt out). When undefined, the NormalizeThinking
* sub-subflow is NOT mounted at chart build time — zero overhead
* for non-thinking agents.
*/
thinkingHandler;
/**
* v2.14+ — request-side thinking budget. When set, every LLMRequest
* carries `thinking: { budget }`. AnthropicProvider translates to the
* wire format. Undefined = no thinking activation (default behavior).
*/
thinkingBudget;
/** Threaded to footprintjs `flowChart()` so every node the Agent
* builder creates is observed by these recorders at build time. Set
* from `opts.structureRecorders`; undefined when consumer didn't
* attach any. */
structureRecorders;
/** Per-COMPOSITION translator (L1b). Set from `opts.groupTranslator`;
* undefined when consumer didn't attach one. */
agentGroupTranslator;
/** ReAct loop mode — 'dynamic' (default, re-engineer all slots each turn,
* flat chart), 'classic' (engineer context once, loop→Messages only, flat
* chart), or 'dynamic-grouped' (dynamic semantics + LLM turn wrapped in an
* sf-llm-call subflow for richer Lens grouping). Set from `opts.reactMode`.
* See AgentOptions. */
reactMode;
/** The recipes `.recipe()` applied, in declaration order. Held for ONE
* purpose: the run manifest's `recipes` rows, so a recording can say which
* composition produced the agent that answered. Undefined — never `[]` — on
* every agent built without one, which is what keeps the manifest of an
* agent that uses no recipes byte-identical to the one it emitted before
* they existed. */
appliedRecipes;
/** The DECLARED skill map (9.50.0) — nodes + edges verbatim, captured by
* `AgentBuilder.skillGraph()`. Filed once per run as
* `agentfootprint.skill.graph_declared`; undefined = no graph, or a graph
* that could not state its map (the event then never fires). */
skillGraphDeclared;
/** The maps kernel's plan (9.58.0) — present only when built with `.maps()`. */
mapsPlan;
/** `.claims()` (9.61.0) — the declared claim contract, or undefined. */
claimContract;
/** `AgentOptions.recordSystemPrompt` (9.50.0) — OFF by default. When true,
* every `stream.llm_start` carries the assembled system prompt verbatim as
* `systemPromptText`. */
recordSystemPromptValue = false;
/** `AgentOptions.recordReceipt` (9.88.0) — ON by default. `false` declines
* the mint at `callLLM`; nothing else about the run changes. */
recordReceiptValue = true;
/** The receipt memo every call of a run mints through — one per agent,
* rebinding itself to each run id (`core/agent/receipt/receiptDigests.ts`). */
receiptDigests = createReceiptDigests();
answerValidationConfig;
negativeClaimsConfig;
typedNegativeClaimsConfig;
applicationAnswerValidation;
constructor(opts, systemPromptValue, registry, voice, injections = [], memories = [], outputSchemaParser, toolProvider, systemPromptCachePolicy = 'always', cachingDisabled = false, cacheStrategy, outputFallbackCfg, reliabilityConfig, thinkingHandlerValue, thinkingBudgetValue, skillGraphNextSkill, skillGraphReachable, skillGraphScoreEntries, checkInOptions, runConfigFn, windowStrategy, toolMiddleware, messageMiddleware, outputEnforcement, skillGraphEdgeTargets, skillGraphExplainNextSkill, skillGraphIsTree, skillGraphSupersededEntries, skillGraphCascade, skillBrains, evidenceGate, limitsTravelWithTheAnswer, recipes, skillGraphDeclared, mapsPlan, claimContract, answerValidationConfig, systemPromptCacheExplicit = false, negativeClaimsConfig, scriptTravel) {
super();
// Which conversation this instance's self-explain evidence belongs to —
// held in a module registry, not on the class, so it is not public API
// (`agent/servingConversation.ts`).
registerServingConversation(this, () => ({
conversation: this.servingConversation,
runId: this.currentRunContext.runId,
...(this.servingConversation?.startsWith('hosted:') === true && { oneShot: true }),
}));
this.negativeClaimsConfig = negativeClaimsConfig;
this.typedNegativeClaimsConfig =
negativeClaimsConfig?.mode === 'typed' ? negativeClaimsConfig : undefined;
this.applicationAnswerValidation = answerValidationConfig !== undefined;
this.answerValidationConfig =
answerValidationConfig ??
(this.typedNegativeClaimsConfig === undefined
? undefined
: resolveAnswerValidation({
id: 'negative-claims',
version: '1',
mode: 'observe',
validate: () => ({ checks: [] }),
}));
this.provider = opts.provider;
this.name = opts.name ?? 'Agent';
this.id = opts.id ?? 'agent';
if (opts.id !== undefined)
this.explicitId = opts.id;
this.model = opts.model;
this.temperature = opts.temperature;
this.maxTokens = opts.maxTokens;
this.maxIterations = clampIterations(opts.maxIterations ?? 10);
this.structureRecorders = opts.structureRecorders;
this.agentGroupTranslator = opts.groupTranslator;
this.reactMode = opts.reactMode ?? 'dynamic';
this.systemPromptValue = systemPromptValue;
this.systemPromptCachePolicy = systemPromptCachePolicy;
this.cachingDisabledValue = cachingDisabled;
// The strategy the provider's DECLARED prompt caching selects, unless the
// caller overrides it — never one looked up by `provider.name`, which a
// renaming decorator (`withRetry`, an app's own wrapper) rewrites. A
// provider that declares nothing gets the no-op strategy.
this.cacheStrategy = cacheStrategy ?? cacheStrategyFor(opts.provider, opts.model);
this.cacheStrategyIsOverride = cacheStrategy !== undefined;
this.cacheUnsupported = cacheRequestedWithoutSupport({
strategy: this.cacheStrategy,
providerName: opts.provider.name,
systemPrompt: systemPromptValue,
systemPolicy: systemPromptCachePolicy,
systemExplicit: systemPromptCacheExplicit,
modelAwareUnavailable: cacheStrategy === undefined &&
typeof opts.provider.promptCaching === 'function' &&
this.cacheStrategy.name === 'none',
injections,
disabled: cachingDisabled,
});
this.registry = registry;
this.injections = injections;
this.skillGraphNextSkill = skillGraphNextSkill;
this.skillGraphReachable = skillGraphReachable;
this.skillGraphScoreEntries = skillGraphScoreEntries;
this.skillGraphEdgeTargets = new Set(skillGraphEdgeTargets ?? []);
this.skillGraphExplainNextSkill = skillGraphExplainNextSkill;
this.skillGraphSupersededEntries = skillGraphSupersededEntries;
this.skillGraphIsTree = skillGraphIsTree ?? false;
this.skillGraphCascade = skillGraphCascade;
this.skillBrains = skillBrains;
// Under an enforcing answer validation the gate's revision calls no tools
// (`ResolvedEvidenceGate.toolsOffAfterRevision`): one fact on the one gate
// every reader is already handed.
this.evidenceGate =
evidenceGate !== undefined &&
evidenceGate.posture !== 'assist' &&
(answerValidationConfig?.mode === 'enforce' || this.typedNegativeClaimsConfig !== undefined)
? { ...evidenceGate, toolsOffAfterRevision: true }
: evidenceGate;
this.limitsTravelWithTheAnswerValue = limitsTravelWithTheAnswer === true;
this.scriptTravel = scriptTravel;
this.appliedRecipes = recipes;
this.skillGraphDeclared = skillGraphDeclared;
this.mapsPlan = mapsPlan;
this.claimContract = claimContract;
if (opts.recordSystemPrompt === true)
this.recordSystemPromptValue = true;
// The receipt's OFF SWITCH (9.88.0). Default ON, so only an explicit
// `false` is remembered and every other agent's dep bag is byte-identical.
if (opts.recordReceipt === false)
this.recordReceiptValue = false;
this.memories = memories;
this.outputSchemaParser = outputSchemaParser;
this.outputEnforcement = outputEnforcement;
this.outputFallbackCfg = outputFallbackCfg;
this.externalToolProvider = toolProvider;
// Eager validation: tool names must be unique across .tool() +
// every Skill.inject.tools — the LLM dispatches by name. Runs in
// constructor so `Agent.build()` throws immediately on collision,
// not at first run().
validateToolNameUniqueness(registry, injections);
// Eager validation: memory ids must be unique so per-id scope keys
// (`memoryInjection_${id}`) don't collide.
validateMemoryIdUniqueness(memories);
// Evidence bridge (#5): a CAUSAL memory gets a run-scoped harvest recorder
// (decisions/toolCalls/iterations/duration/tokens). Attached per run below;
// its `collect` is threaded into the write mount via chartDeps.
if (memories.some((m) => m.type === 'causal')) {
const evidence = causalEvidenceRecorder();
this.causalEvidence = evidence;
// A causal memory is working state: its store keeps what a later run
// replays to the MODEL, so its tool calls are read on the real-value
// path, never off the served events — a policy keeps a run's records
// clean, never what the agent will compute on (`src/redaction/`). The
// decisions it keeps come from footprintjs's flow channel, which serves
// them under the policy (a named limit in `src/redaction/README.md`).
this.dispatcher.onRealEvent((event) => evidence.onEmit({ name: event.type, payload: event.payload }));
}
// A dial without its switch — refused rather than run as a no-op (8.13.0).
// Same policy as `observerDeliveryOptions` below; the message and the check
// are shared with LLMCall, which takes the identical pair.
assertCostBudgetHasPricing('Agent', opts.pricingTable, opts.costBudget);
if (opts.pricingTable)
this.pricingTable = opts.pricingTable;
// Normalized once, at build: `onExceed` decides whether the Route decider
// stops the loop, and a policy resolved per-run could differ per-run.
const resolvedCostBudget = resolveCostBudget('Agent', opts.costBudget);
if (resolvedCostBudget !== undefined)
this.costBudget = resolvedCostBudget;
if (opts.contextBudget !== undefined)
this.contextBudget = opts.contextBudget;
if (opts.permissionChecker)
this.permissionChecker = opts.permissionChecker;
if (opts.toolArgValidation !== undefined)
this.toolArgValidation = opts.toolArgValidation;
if (opts.findings !== undefined)
this.findingsOptions = {
...opts.findings,
maxContextBytes: findingsContextBytes(opts.findings.maxContextBytes),
};
// The inputs layer's door (honesty layer 2): `true`, or `{ argumentSources }`
// — either form mounts the layer; the object form may also arm declared
// sources without the findings ledger.
const inputsLayer = readInputsLayerOption(opts.inputsLayer);
if (inputsLayer !== undefined)
this.inputsLayerOption = true;
if (opts.resultsLayer === true)
this.resultsLayerOption = true;
if (opts.answerLayer === true)
this.answerLayerOption = {};
else if (typeof opts.answerLayer === 'object' && opts.answerLayer !== null) {
// The builder judged the option (`AgentBuilder.ts` · `answerLayerArm`); `true` is 'always'.
const line = opts.answerLayer.standingLine;
this.answerLayerOption =
line === true
? { standingLine: 'always' }
: typeof line === 'object' && line !== null
? { standingLine: line.when }
: {};
}
// The time layer's door (`.time()` / `AgentOptions.time`): `true` or the
// options, read once; a malformed zone is refused here, at build.
if (opts.time !== undefined) {
const read = readTimeOptions(opts.time === true ? undefined : opts.time);
if ('problem' in read)
throw new Error(`Agent: time ${read.problem}.`);
this.timeOptions = read.value;
// The reader reads only a person's message — a composition hands the
// `messageFrom: 'composed'` marker only to a runner that reads it.
if (read.value.reader !== undefined)
readsMessageFrom(this);
}
if (opts.findings?.argumentSources === true || inputsLayer?.argumentSources === true) {
this.argumentSourcesArmed = true;
// Declared sources read `AgentInput.messageFrom` — a composition hands
// the marker only to a runner that reads it.
readsMessageFrom(this);
}
// A member runs for the run that mounted it: a composition hands it that
// run as `parentRun` (`core/memberRun.ts`).
readsParentRun(this);
if (opts.argumentAskContext !== undefined)
this.argumentAskContext = opts.argumentAskContext;
if (opts.toolChoice !== undefined)
this.toolChoiceOptions = opts.toolChoice;
if (opts.ontology !== undefined)
this.ontology = opts.ontology;
// The tool-result ceiling (9.11.0). Refused HERE, naming the value, rather
// than at the first tool call of the first run — a dial that cannot cap
// anything is a configuration mistake, not a runtime condition.
assertMaxToolResultChars('Agent', opts.maxToolResultChars);
if (opts.maxToolResultChars !== undefined)
this.maxToolResultChars = opts.maxToolResultChars;
// Resolve check-in config once. Always present (default: standard evidence
// + lexical scorer) so a `checkIn`-declaring tool works even without a
// `.checkIn()` call; the gate only fires for tools that declared `checkIn`.
this.checkInConfig = resolveCheckInConfig(checkInOptions);
// Refused HERE, not at the first resume: a ledger that cannot spend would
// refuse every decision, and that is a configuration mistake.
if (opts.decisionLedger !== undefined) {
if (typeof opts.decisionLedger?.spend !== 'function') {
throw new TypeError('Agent: `decisionLedger` must have a `spend(requestId)` method — see ' +
"`DecisionLedger`, or omit it for this process's in-memory ledger.");
}
this.decisionLedger = opts.decisionLedger;
}
if (runConfigFn !== undefined)
this.runConfigFn = runConfigFn;
// `.window()` / `.compaction()`: the meter is created ONCE per agent
// (attached per run, cleared between runs like every other recorder)
// because the window stage closes over it at chart-build time — and the
// chart is built once.
if (windowStrategy !== undefined) {
this.windowStrategy = windowStrategy;
const meter = compactionMeter();
this.compactionMeterHandle = meter;
this.evictedTurnsHandle = createEvictedTurnsHandle();
// The provider's token count is a MEASUREMENT the window decides on, so
// the meter reads it on the real-value path, never off the served event:
// a redaction pattern that happens to match `input` must not switch
// compaction off (`src/redaction/runRedaction.ts`).
this.dispatcher.onRealEvent((event) => {
if (event.type === 'agentfootprint.stream.llm_end')
meter.noteLlmEnd(event.payload);
});
}
// The two governance chains. Empty arrays (not undefined) so every read
// site is a plain `.length > 0` test rather than an optional dance.
this.toolMiddleware = toolMiddleware ?? [];
// What an ask's approval holds to, for a host that keeps this agent's
// pauses and must tell one no answer can reach (`consentRequest.ts`).
registerConsentRules(this, () => this.toolMiddleware.map((link) => link.name));
// …and the ledger it spends decisions in, so that host can tell a paused
// call that provably never ran from one whose outcome is not known.
registerConsentLedger(this, this.decisionLedger);
this.messageMiddleware = messageMiddleware ?? [];
// Default 'summary' — measurement-gated (#18): stageReads values have
// zero consumers across af/lens/eui, and 'full' clones ~18MB of unread
// data per 200 iterations. Consumers opt into 'full' explicitly.
this.readTracking = opts.readTracking ?? 'summary';
// Default 'delta' — the accepted #13c-B design: agentfootprint opts in
// immediately (the agent's history-append workload is exactly the case
// the verb exists for; reconstruction stays lossless via commitValueAt).
this.commitValues = opts.commitValues ?? 'delta';
// Default 'off' — the debugging dial is opt-in: on, every write also
// records the keys read before it, which is what upgrades `traceVariable`
// to `coverage: 'exact'` and lets `walkToRoot` hop by recorded dataflow
// instead of embedding similarity.
this.writeProvenance = opts.writeProvenance ?? 'off';
// The redaction door: refused here, where it was declared, when it is not
// a policy footprintjs can apply — then declared for every run of this
// agent and of any composition that mounts it (`src/redaction/declared.ts`).
if (opts.redact !== undefined) {
assertRedactionPolicy(opts.redact, 'Agent.create');
declareRedaction(this, opts.redact);
}
// RFC-001 Block 10 — observer delivery tier. Fail fast on the dials
// without the switch (no silently-ignored combinations; same policy
// that merged reactMode/reactStructure in 6.0.0).
this.observerDelivery = opts.observerDelivery ?? 'inline';
if (opts.observerDeliveryOptions !== undefined && this.observerDelivery !== 'deferred') {
throw new Error("Agent: observerDeliveryOptions requires observerDelivery: 'deferred' — " +
'the dials configure the deferred capture queue and have no meaning inline.');
}
this.observerDeliveryOptions = opts.observerDeliveryOptions;
if (opts.credentials)
this.credentialProvider = opts.credentials;
// Kept only when it says something: `undefined` and `true` are the same
// default, and storing `true` would make the thread-when-off rule below
// read as a coincidence.
if (opts.repeatedCallNudge === false)
this.repeatedCallNudge = false;
// Same discipline (9.56.0): `undefined` and `true` are the same default, so
// only a deliberate opt-out is stored — which keeps the conditional mount
// below reading as the decision it is.
if (opts.wrapUpAtMaxIterations === false)
this.wrapUpAtMaxIterations = false;
// Same discipline (9.57.0): the default is 2, so only a value the
// operator actually named is stored — which keeps the value-conditional
// thread into the window stage reading as the decision it is. Refused at
// construction, never mid-run.
if (opts.keepLastToolResults !== undefined) {
requireKeepLastToolResults(opts.keepLastToolResults, 'Agent');
this.keepLastToolResults = opts.keepLastToolResults;
}
// Its content-aware sibling (9.102.0). The top-level door is refused
// HERE, whether or not `.findings()` is on — a dial that is silently
// ignored is a configuration mistake, not a runtime condition (the
// `.findings()` door was refused by the builder). The value is resolved
// ONCE, under the arm only: the `.findings()` door wins when both are
// given, `false` is `0`, nothing named is the default. Without
// `.findings()` there is no ledger to hold facts from, so the option is
// accepted and does nothing — the keepLastToolResults-without-a-window
// precedent.
if (opts.keepLedgerFacts !== undefined)
requireKeepLedgerFacts(opts.keepLedgerFacts, 'Agent');
if (this.findingsOptions !== undefined) {
// Keep the supported findings execution modes explicit. This change
// restores prefix caching; it does not add findings support to classic.
if (this.reactMode === 'classic') {
throw new Error("Agent: .findings() is supported in 'dynamic' and 'dynamic-grouped' modes only; reactMode 'classic' is not supported.");
}
this.keepLedgerFacts = resolveKeepLedgerFacts(this.findingsOptions.keepLedgerFacts ?? opts.keepLedgerFacts);
}
// Tool choice by classifier (9.105.0) needs the same per-call recomposition
// the ledger does — the pick is made where the served list is built, and
// a narrowed list committed on turn 1 would be served on every later call
// with no pick behind it. The same refusal, at the same place.
if (this.toolChoiceOptions !== undefined && this.reactMode === 'classic') {
throw new Error("Agent: .toolChoice() requires per-iteration slot recomposition — reactMode 'classic' " +
'caches the tools slot on turn 1, so the classifier would be asked once and a narrowed ' +
"list would be served on every later call. Use the default 'dynamic' mode (or " +
"'dynamic-grouped').");
}
// Refused at construction, never mid-run — a misspelled posture that was
// ignored would leave the liveness theorems switched off in an agent
// that believes they are on (the concurrency-mode precedent).
if (opts.integrityPosture !== undefined) {
if (opts.integrityPosture !== 'observe' && opts.integrityPosture !== 'dev') {
throw new Error(`Agent: integrityPosture must be 'observe' or 'dev', got ` +
`${JSON.stringify(opts.integrityPosture)}.`);
}
this.integrityPosture = opts.integrityPosture;
}
// The external-ground door (9.72.0) — refused at construction, never
// mid-run: a non-function here would otherwise fail on the first LLM call
// of the first armed run, far from the line that caused it.
if (opts.externalGrounds !== undefined) {
if (typeof opts.externalGrounds !== 'function') {
throw new Error(`Agent: externalGrounds must be a function returning {value, source} entries, got ` +
`${typeof opts.externalGrounds}.`);
}
this.externalGrounds = opts.externalGrounds;
}
// The write-seam advisory's dial (9.77.0) — refused at construction for
// the same reason as the posture above: a truthy non-boolean here (a
// string, a number) would silently arm a check the author only half
// asked for, and the arming is what decides whether a run is
// byte-identical to the one before it.
if (opts.noticeEmptyLookups !== undefined) {
if (typeof opts.noticeEmptyLookups !== 'boolean') {
throw new Error(`Agent: noticeEmptyLookups must be a boolean, got ` +
`${JSON.stringify(opts.noticeEmptyLookups)}. It arms the write-seam 'empty-lookup' ` +
`advisory — a lookup for a value this run itself produced coming back empty. ` +
`Omit it (or pass false) and no such advisory is ever filed.`);
}
this.noticeEmptyLookups = opts.noticeEmptyLookups;
}
// The claim seam's recency dial (9.83.0) — refused at construction for
// the same reason as the write-seam one above: a truthy non-boolean here
// would silently arm a check the author only half asked for, and the
// arming is what decides whether a run is byte-identical to the one
// before it.
if (opts.noticePriorTurnEvidence !== undefined) {
if (typeof opts.noticePriorTurnEvidence !== 'boolean') {
throw new Error(`Agent: noticePriorTurnEvidence must be a boolean, got ` +
`${JSON.stringify(opts.noticePriorTurnEvidence)}. It arms the claim-seam ` +
`'prior-turn-evidence' advisory — a final answer whose every value was last served ` +
`before this turn — and it needs \`.namesAndNumbersFromEvidence()\` armed beside ` +
`it, because that gate owns the extractor that decides which tokens are values. ` +
`Omit it (or pass false) and no such advisory is ever filed.`);
}
this.noticePriorTurnEvidence = opts.noticePriorTurnEvidence;
}
// The column-type contract's dial (9.78.0) — refused at construction for
// the same reason as the postures above, and with one more: `'enforce'`
// REFUSES tool results, so a misspelling silently downgraded to off would
// leave an operator believing a boundary is held that nothing is holding.
if (opts.checkColumnTypes !== undefined) {
if (opts.checkColumnTypes !== 'off' &&
opts.checkColumnTypes !== 'warn' &&
opts.checkColumnTypes !== 'enforce') {
throw new Error(`Agent: checkColumnTypes must be 'off', 'warn' or 'enforce', got ` +
`${JSON.stringify(opts.checkColumnTypes)}. It reads each tool's declared ` +
`\`resultColumns\` and judges the rows it returns: 'warn' files findings and ` +
`changes nothing the model reads; 'enforce' refuses the rows and hands the model a ` +
`teaching sentence instead. Omit it (or pass 'off') and nothing is ever measured.`);
}
this.checkColumnTypes = opts.checkColumnTypes;
}
// The claim-check seam (9.21.0). One store per agent, attached at
// construction — idempotent by shape: there is no second door to attach a
// competing one through, so "one per agent" is a fact of the type rather
// than a runtime check. Since 9.22.0 the option also takes the object
// form `{ store, placement? }` — normalized HERE so every downstream
// reader keeps seeing exactly one store and one optional dial.
if (opts.artifacts) {
if ('store' in opts.artifacts) {
// The object form. A shape with `placement` but no store cannot be
// SPELLED in TypeScript, but plain JavaScript can hand us one — and a
// threshold with nowhere to put what it catches is configuration
// that lies, so it is refused by name rather than half-read.
if (opts.artifacts.store === undefined || opts.artifacts.store === null) {
throw new Error('Agent: artifacts was passed in its object form without a `store`. The placement ' +
'threshold refs results INTO the store, so it cannot exist without one — pass ' +
'`artifacts: { store: inMemoryArtifacts(), placement: { maxInlineChars } }` (or ' +
'fileArtifacts / sqliteArtifacts / any ArtifactStore), or drop `artifacts`.');
}
assertArtifactPlacement('Agent', opts.artifacts.placement);
this.artifactStore = opts.artifacts.store;
if (opts.artifacts.placement !== undefined) {
this.artifactPlacement = opts.artifacts.placement;
}
// The recordings dial (9.26.0). `true` and `{ … }` normalize to ONE
// shape here, so every downstream reader asks a single question
// ("is there a recordings option?") rather than re-deriving the union.
if (opts.artifacts.recordings === true) {
this.artifactRecordings = {};
}
else if (typeof opts.artifacts.recordings === 'object' &&
opts.artifacts.recordings !== null) {
this.artifactRecordings = opts.artifacts.recordings;
}
}
else {
this.artifactStore = opts.artifacts;
}
}
// The third dial-without-its-switch (8.13.0). `onAuthorizationRequired` is
// read at exactly one place — the tool-dispatch loop, AFTER
// `credentials.getCredential` came back `authorization-required`. With no
// provider that call never returns at all: the fail-closed stand-in
// (`unconfiguredCredentialProvider`) rejects, so the branch this option
// governs is unreachable and the setting decides nothing.
if (opts.onAuthorizationRequired !== undefined && opts.credentials === undefined) {
throw new Error('Agent: onAuthorizationRequired was set without a `credentials` provider, so it can ' +
'never be reached — it decides what happens when a DECLARED credential comes back ' +
"'authorization-required', and with no provider no credential is ever requested (the " +
'fail-closed stand-in throws instead). Pass `credentials` — agentCoreIdentity({ ' +
'region }), staticTokens({ … }), or any CredentialProvider from ' +
"'agentfootprint/security' — or drop `onAuthorizationRequired`.");
}
// 8.6.0 — default `'pause'`: consent is work waiting on a person, and the
// model is the one party that cannot click a link.
this.onAuthorizationRequired = opts.onAuthorizationRequired ?? 'pause';
// 9.7.0 — teardown is on the SIGTERM path, so it is bounded by default.
this.toolTeardownTimeoutMs = opts.toolTeardownTimeoutMs ?? TOOL_TEARDOWN_TIMEOUT_MS;
if (reliabilityConfig !== undefined)
this.reliabilityConfig = reliabilityConfig;
// v2.14 — Resolve thinking handler. Three states:
// - thinkingHandlerValue === undefined → the provider's DECLARED handler
// - thinkingHandlerValue === null → opt out (no handler)
// - thinkingHandlerValue: ThinkingHandler → explicit override
// A provider that declares none gets no subflow at chart build time.
// Never chosen by `provider.name`: a renaming wrapper that forwards the
// declaration keeps it.
const handler = thinkingForProviders(opts.provider, reliabilityConfig?.providers?.map((entry) => entry.provider), thinkingHandlerValue);
if (handler !== undefined)
this.thinkingHandler = handler;
if (thinkingBudgetValue !== undefined)
this.thinkingBudget = thinkingBudgetValue;
this.appName = voice.appName;
this.commentaryTemplates = voice.commentaryTemplates;
this.thinkingTemplates = voice.thinkingTemplates;
// Eager chart construction — see `RunnerBase.initChart` JSDoc.
// Note re Agent specifics (footprintjs inventor's review):
// - `providerToolCache: { current: Tool[] }` is closed over by the
// chart's Discover + dispatch stages. It's shared across
// sequential runs of this Agent instance, but the Discover stage
// overwrites `current` at the start of every iteration (line 158
// of `buildToolsSlot.ts`), so stale data never reaches a tool
// call. Concurrent runs on the SAME Agent instance already share
// `currentRunContext` and the recorder dispatcher — eager build
// doesn't change that constraint.
// - `currentRunContext` is read by the per-stage `getRunCtx`
// lambda at execution time (not at chart-build time), so a fresh
// value per run still flows through correctly.
this.initChart(() => this.buildChart());
}
static create(opts) {
return new AgentBuilder(opts);
}
/**
* Cache policy for the base system prompt. Read by the CacheDecision
* subflow (v2.6 Phase 4) to know how to treat the SystemPrompt slot's
* cache markers. Exposed as a method (not direct field access) so
* the Agent's encapsulation boundary stays clean.
*/
getSystemPromptCachePolicy() {
return this.systemPromptCachePolicy;
}
/**
* The artifact store this agent was built with, or `undefined` when none
* was attached (9.23.0).
*
* For COMPOSERS that resolve refs on the agent's behalf — the hosting
* layer's `artifact-head` / `artifact-get` wire operations redeem a
* screen's claim tickets against exactly this store. It is the store, not
* a scope-bound capability: whoever calls it owns composing the resolution
* scope (the hosting layer composes the requesting session's identity, the
* same tuple the run's own tools resolved under). Tools never touch this —
* `ctx.artifacts` is already bound to the run's scope, and that remains
* their only door.
*/
getArtifactStore() {
return this.artifactStore;
}
/**
* Start recording this run — or do nothing at all (9.26.0).
*
* Zero-cost when unused is not a claim here, it is the control flow: with
* `recordings` unset this returns `undefined` before touching the agent, so
* no listener is subscribed, no boundary recorder is attached, and the run
* is byte-identical to every earlier release.
*
* It is deliberately the SAME recorder a consumer would call by hand
* (`recordRun`'s own body, `recordRunWhere`). Nothing about this feature is a
* second recording implementation — the three connections that a
* hand-rolled version gets wrong (attach, subscribe, getCommitCount) are
* wired in exactly one place in this package, and this is a caller of it.
* The one difference is the timeline's filter: a recording filed FOR a
* person keeps only the events of that person's run.
*/
startRunRecording() {
if (this.artifactRecordings === undefined || this.artifactStore === undefined)
return undefined;
// Only THIS run's events: on an instance serving several sessions, the
// hosting door emits facts for other sessions while this run is in flight,
// and a recording is redeemable by the person it was filed for.
return recordRunWhere(this, (event) => this.ownsEvent(event));
}
/**
* Does `event` belong to the run this agent is serving (or served last)? The
* run-membership rule (`bridge/eventMeta.ts · eventBelongsToRun`) against
* this instance's run context — asked by the per-run recording and by the
* self-explain evidence, the two collectors that keep one run's events.
*
* @internal
*/
ownsEvent(event) {
return eventBelongsToRun(event.meta, this.currentRunContext);
}
/**
* File the finished recording into the artifact store.
*
* ── When ────────────────────────────────────────────────────────────────
* After the answer is composed, and only for a run that COMPLETED. A pause
* is not a finished run (the turn continues, and the resume mints its own);
* a throw never reaches here at all.
*
* ── Why it is awaited ───────────────────────────────────────────────────
* The answer is final before this begins and this cannot change it — but
* `run()` does return after the write rather than before, and that is a
* choice rather than an oversight. A fire-and-forget write is a recording
* lost whenever the process exits with the reply, which is precisely the
* serverless deployment that wants recordings most. The cost is one store
* write per turn, stated on the option.
*
* ── Why it can never fail the run ───────────────────────────────────────
* A full store, an unserializable snapshot, a bucket that 500s — none of
* them are facts about the ANSWER, which is already correct and already
* paid for. Turning "your recording was not filed" into "your request
* failed" would be the library deciding that its observability matters more
* than the user's turn. So every failure degrades to today's path: the
* answer is returned unchanged and the reason lands on the record as
* `agentfootprint.artifacts.refused`, where a sink can count it.
*
* The recording is FROZEN before the mint, so it can never contain the
* `artifacts.minted` event describing itself.
*/
async fileRunRecording(recorder, outcome) {
const store = this.artifactStore;
if (recorder === undefined || store === undefined || this.artifactRecordings === undefined) {
return;
}
if (isPaused(outcome))
return;
const runId = this.currentRunContext?.runId;
let input;
try {
const recording = recorder.toRecording();
input = recordingPutInput(this.artifactRecordings.packed === true ? packRecording(recording) : recording, {
...(runId !== undefined && { runId }),
...(this.artifactRecordings.label !== undefined && {
label: this.artifactRecordings.label,
}),
});
}
catch (err) {
this.reportRecordingRefused(err, 'invalid-input');
return;
}
try {
const result = await store.put(this.runArtifactScope(), input);
// Sweeps ride the put result, so retention that evicted an older
// recording to make room says so on the record — the same law a tool's
// own mint follows.
for (const swept of result.swept) {
this.emit('agentfootprint.artifacts.expired', {
ref: swept.ref,
reason: swept.reason,
kind: swept.kind,
bytes: swept.bytes,
});
}
this.emit('agentfootprint.artifacts.minted', {
ref: result.meta.ref,
kind: result.meta.kind,
mediaType: result.meta.mediaType,
bytes: result.meta.bytes,
...(result.meta.label !== undefined && { label: result.meta.label }),
...(result.meta.expiresAt !== undefined && { expiresAt: result.meta.expiresAt }),
...(result.meta.origin !== undefined && { origin: result.meta.origin }),
});
}
catch (err) {
this.reportRecordingRefused(err, 'invalid-input');
}
}
/** One refusal fact for a recording that could not be filed. The message is
* the store's own, which never carries a payload — only what went wrong. */
reportRecordingRefused(err, reason) {
this.emit('agentfootprint.artifacts.refused', {
op: 'put',
reason,
detail: err instanceof Error ? err.message : String(err),
});
}
/**
* The run's artifact scope, read from the finished run's own state.
*
* The SAME tuple `ctx.artifacts` bound during the run (`scope.runIdentity`,
* composed by seed from the caller's identity or derived from the session).
* Read rather than recomposed: a second derivation here could disagree with
* the one the run's tools used, and a recording filed in a different scope
* from the artifacts it describes is a recording nobody can find.
*/
runArtifactScope() {
const identity = this.liveSnapshot()?.sharedState
?.runIdentity;
if (identity === undefined) {
// No state to read means no run happened, which this path cannot reach —
// but a scope is required and inventing a tenant would be worse than a
// conversation id that names the run itself.
return { conversationId: this.currentRunContext?.runId ?? 'unknown' };
}
return {
conversationId: identity.conversationId,
...(identity.tenant !== undefined && { tenant: identity.tenant }),
...(identity.principal !== undefined && { principal: identity.principal }),
};
}
/**
* The footprintjs `RuntimeSnapshot` from the most recent `run()` /
* `resume()`. Feeds Lens's Trace tab (ExplainableShell `runtimeSnapshot`
* prop) so consumers can scrub the execution timeline post-run without
* threading a recorder through the call site.
*
* `undefined` until a run has STARTED. After that it is the most recent
* run's snapshot — including across multiple turns of the same instance.
*
* **It is LIVE during a run, not a completed-runs-only view.** The executor
* is assigned at run start, so calling this from an event listener, a tool,
* or any other mid-run vantage point returns the IN-FLIGHT run, partially
* filled. That is deliberate (Lens scrubs a running agent through it), and
* it is why `.selfExplain()` captures at the terminal flush instead of
* resolving through this: evidence that is supposed to describe a FINISHED
* turn cannot be read from a getter that also answers about an unfinished
* one.
*
* **Under `redact` it is the SERVED snapshot** — footprintjs's redacted view
* (`RunnerBase.getLastSnapshot`): the placeholder wherever the policy
* selected a value, and no `initialState`. The agent's own logic never reads
* this getter; `checkpoint()`, `followUp()` and the crash checkpoint read the
* live run, so redaction never changes what the agent continues from.
*/
getLastSnapshot() {
return super.getLastSnapshot();
}
/**
* Structured narrative entries from the most recent run. Pairs with
* `getLastSnapshot()` for ExplainableShell's `narrativeEntries` prop.
* Empty array (not `undefined`) when no run has completed — matches
* the prop's expected shape so consumers can wire it directly without
* a defensive guard.
*
* Under `redact`, footprintjs wrote these lines through the run's rule: a
* read or write of a selected key reads `[REDACTED]`, and an `[emit]` line
* shows the event's served payload (`src/redaction/runRedaction.ts`).
*/
getLastNarrativeEntries() {
if (this.lastExecutor === undefined)
return [];
// The snapshot's terms: an executor no run of this agent opened serves
// nothing — its narrative was written under no rule anyone knows.
if (coverageOfExecutor(this.lastExecutor).state === 'unknown')
return [];
return this.lastExecutor.getNarrativeEntries();
}
/**
* The FlowChart compiled for the most recent run (or a freshly-built
* one if no run has happened yet). Feeds ExplainableShell's `spec`
* prop. Returning the cached chart matters: the spec must match what
* `getLastSnapshot()` traced, otherwise the Trace view's stage tree
* desyncs from the snapshot's runtime tree.
*/
// `getSpec()` inherited from RunnerBase — returns the cached chart
// built once at constructor time via `initChart()`. Same reference
// every call, same reference the executor traces.
// ─── UI group translation (L1b) ───────────────────────────────
getGroupTranslator() {
return this.agentGroupTranslator;
}
/** Agent has no nested-runner members (tools are function executors,
* not Runner instances). Slot ids + tool names live in `extra` so
* Lens can render an Agent card with slot rows + a tool list without
* inspecting `buildTimeStructure`.
*
* Memories are NOT included as members — they're an internal
* mechanism, not a composition-level concept. Consumers who need
* memory visibility should listen for `agentfootprint.memory.*`
* events at runtime. */
buildUIGroupMetadata() {
const toolNames = this.registry.map((r) => r.name);
return {
kind: 'Agent',
id: this.id,
name: this.name,
members: [],
extra: {
slots: [SUBFLOW_IDS.SYSTEM_PROMPT, SUBFLOW_IDS.MESSAGES, SUBFLOW_IDS.TOOLS],
toolNames,
maxIterations: this.maxIterations,
},
};
}
/**
* Parse + validate a raw agent answer against the agent's
* `outputSchema` parser. Throws `OutputSchemaError` on JSON parse
* or schema validation failure (the rawOutput is preserved on the
* error for triage). Throws a plain `Error` if the agent has no
* outputSchema set.
*
* Use this when you need to keep `agent.run()` returning the raw
* string for logging/observability and validate at a different
* layer; otherwise prefer `agent.runTyped()`.
*/
parseOutput(raw) {
if (!this.outputSchemaParser) {
throw new Error(`Agent.parseOutput: this agent has no outputSchema. Use ` +
`Agent.create({...}).outputSchema(parser).build() to enable typed output.`);
}
return applyOutputSchema(raw, this.outputSchemaParser);
}
/**
* Async sister of `parseOutput()`. When the agent is configured
* with `.outputFallback({...})`, this is the version that engages
* the 3-tier degradation chain on validation failure (the sync
* `parseOutput` always throws on failure for back-compat).
*
* Without `outputFallback`, behaves identically to `parseOutput`
* — returns sync-style on the happy path, throws OutputSchemaError
* on validation failure.
*/
async parseOutputAsync(raw) {
if (!this.outputSchemaParser) {
throw new Error(`Agent.parseOutputAsync: this agent has no outputSchema. Use ` +
`Agent.create({...}).outputSchema(parser).build() to enable typed output.`);
}
const parser = this.outputSchemaParser;
try {
return applyOutputSchema(raw, parser);
}
catch (err) {
return this.settleTypedFailure(raw, err);
}
}
/**
* A typed answer that failed: rethrow, or — with `.outputFallback()` and an
* `OutputSchemaError` — hand it to the 3-tier fallback. The one settle for
* `parseOutputAsync`'s parse failure and `runTyped`'s native stop failure.
*/
async settleTypedFailure(raw, err) {
const parser = this.outputSchemaParser;
if (!this.outputFallbackCfg || !(err instanceof OutputSchemaError))
throw err;
// Engage the 3-tier fallback. The dispatcher gives us the
// typed-event entry; we synthesize a minimal event shape since
// these events have no per-stage anchor.
// Typed via `OutputFallbackEmit`, so the registry — not a grep — is what
// decides these two names are real. The cast is now only the envelope
// assembly (type + payload are already checked against the event map).
const emit = (eventType, payload) => {
try {
this.dispatcher.dispatch({
type: eventType,
timestamp: Date.now(),
payload,
});
}
catch {
/* observability errors must not poison the fallback path */
}
};
return applyOutputFallback(raw, parser, this.outputFallbackCfg, emit, err, this.lastRunRetriesSpent());
}
/**
* Corrective re-asks the agent's LAST run paid for, read off its own ledger.
*
* `undefined` when there is no last run to read — `parseOutputAsync` accepts
* any string, including one that never came from this agent, and reporting
* `0` for "I do not know" would be an invented fact in an event payload.
*/
lastRunRetriesSpent() {
const state = this.liveSnapshot()?.sharedState;
if (state === undefined)
return undefined;
return Math.max(0, (state.outputAttempts?.length ?? 1) - 1);
}
/**
* Run the agent and return the schema-validated typed output.
* Convenience over `parseOutputAsync(await agent.run({...}))`.
*
* Throws `OutputSchemaError` on parse / validation failure UNLESS
* `.outputFallback({...})` is configured, in which case the
* 3-tier degradation chain (primary → fallback → canned) engages.
*
* Throws if the agent has no outputSchema set or if the run
* pauses (use `run()` directly when pauses are expected).
*
* With `.limitsTravelWithTheAnswer()`, nothing is appended to a typed
* answer: the limits its tools declared come back as data, from
* `agent.answerCoverage()`.
*/
async runTyped(input, options) {
if (!this.outputSchemaParser) {
throw new Error(`Agent.runTyped: this agent has no outputSchema. Use ` +
`Agent.create({...}).outputSchema(parser).build() to enable typed output.`);
}
const out = await this.run(input, options);
if (typeof out !== 'string') {
throw new Error('Agent.runTyped: run paused — typed mode does not support pauses. ' +
'Use agent.run() + agent.parseOutput(...) after resume.');
}
// `strategy: 'native'`: an answer whose last attempt failed on a refusal
// or truncation stop is refused here even when its text parses — the
// format did not hold for it (outputEnforcement.ts · nativeStopFailure).
// FIRST, before any delivery path. It takes the same path a parse failure
// takes, `.outputFallback()` included.
const stopped = this.outputEnforcement?.outputFormat !== undefined ? this.outputContractUnmet() : undefined;
if (stopped?.stopReason !== undefined) {
return this.settleTypedFailure(out, new OutputSchemaError(stopped.error, { rawOutput: out, stage: stopped.stage }));
}
if (this.answerValidationConfig !== undefined) {
// The validator judged the schema's JSON output. Do not run a transforming
// or stateful parser again and hand back a value the validator never saw.
if (this.answerValidation()?.schemaAccepted !== true) {
throw new OutputSchemaError('Answer validation could not establish a JSON schema result.', {
rawOutput: out,
stage: 'schema-validate',
});
}
return applyOutputSchema(out, { parse: (value) => value });
}
return this.parseOutputAsync(out);
}
/**
* Answer one turn.
*
* **`run()` is ONE turn, and it starts a new conversation every time.** The
* chart seeds its history from this call's `message` alone, so a second
* `run()` on the same agent does not continue the first: the model is shown
* one user message and will honestly tell your user it has not spoken to
* them before. That is deliberate — a primitive that quietly accumulated
* state across calls could never be used for one-shot work, and a hidden
* transcript is the most expensive thing an agent can carry.
*
* To continue a conversation, name it:
*
* - `agent.followUp(message)` — continue THIS agent's own last completed
* run. The one-liner, and what most callers want.
* - `run({ message, continueFrom })` — continue a conversation you are
* holding: `agent.checkpoint()` from an earlier turn, persisted anywhere
* and handed back. Works across a restart, a deploy, or a different
* machine, and is what `standingAgent` uses per session.
*
* Passing the same `identity.conversationId` to two `run()` calls does NOT
* continue anything — see {@link AgentInput.identity}. What a registered
* memory adds is *recall* of prior turns into the system-prompt slot, which
* is a different thing from the conversation itself.
*
* Two refusals guard the per-instance state this agent keeps; both replace
* behavior that used to succeed while quietly being wrong (9.2.0):
* {@link RunInFlightError} when a run is already in flight, and
* {@link PendingQuestionError} when the last run paused to ask a person
* something that nobody has answered.
*
* @example One turn, then a follow-up
* ```ts
* await agent.run({ message: 'Book me a table for two.' });
* await agent.followUp('Make it three.'); // remembers the table
* ```
*/
async run(input, options) {
// Normalize or refuse BEFORE anything is created. A bare string is the
// message; anything that is not a message is named and refused here
// rather than becoming `content: undefined` inside the messages slot.
const runInput = normalizeRunInput(input, 'Agent.run');
// Who wrote the message (declared sources' one reader): one of two words,
// or nothing — a typo would read as the person's words, so it is refused.
const messageFrom = runInput.messageFrom;
if (messageFrom !== undefined && messageFrom !== 'person' && messageFrom !== 'composed') {
throw new InvalidRunInputError({
runner: 'Agent.run',
received: `messageFrom: ${typeof messageFrom === 'string' ? JSON.stringify(messageFrom) : typeof messageFrom}`,
hint: "messageFrom is 'person' (the default) or 'composed' (another runner's output)",
});
}
// An identity that is not one is refused HERE, where the mistake was made —
// not at the resume of a pause it would have made impossible to resume.
assertIdentityShape(runInput.identity, 'Agent.run');
assertIdentityShape(options?.identity, 'Agent.run');
// The incoming request, sealed — refused here when `requestContext` did not build it.
const runRequest = requestOf(options, 'Agent.run');
// A policy handed down for this run is refused here, before anything moves,
// by the same check `Agent.create({ redact })` gets.
if (options?.redact !== undefined)
assertRedactionPolicy(options.redact, 'Agent.run');
// The run's clock (the time layer): read or refused HERE, before the turn
// starts — a run with no zone anywhere runs with its zone UNKNOWN (G15),
// never on the server's zone.
const clockDraft = this.clockDraftFor(runInput.time ?? options?.time, 'Agent.run');
// The engine's options; the run's signal (and its deadline) is armed just
// before the run starts and released in its `finally` (`runSignalOf`).
const engineOptions = withoutTime(options);
// Timing next, and before the executor exists: both of these refuse a call
// that would have SUCCEEDED into corrupted per-instance state or an
// orphaned human question. See ./conversation.ts for why they are throws.
this.assertNotRunning('Agent.run');
this.assertNoPendingQuestion('Agent.run');
// A conversation handed in continues through the same side channel
// `resumeOnError` uses — one restoration path, so the two doors cannot
// drift about what "continue" means. This turn's message IS appended:
// continuing a conversation adds a turn to it. Seed appends it, AFTER the
// input middleware chain, so the entry is the chain's verdict (9.112.2).
let continued;
if (runInput.continueFrom !== undefined) {
continued = validateCheckpoint(runInput.continueFrom);
this.applyContinuation(continued, 'Agent.run({ continueFrom })', true);
}
// Only an EXPLICIT identity is remembered for `checkpoint()`; see the
// field's note. `input.identity` wins over `options.identity` because the
// input bag is where a caller looks first, and both win over the stored
// conversation's — but the conversation's is used when neither was given,
// so a continued turn stays in the namespace it started in.
this.lastRunIdentity =
runInput.identity ?? options?.identity ?? (continued ? continued.identity : undefined);
// Recordings-as-artifacts (9.26.0). BEFORE `createExecutor`, because
// `attach()` collects recorders for the executor that has not been built
// yet — a recording started one line later would be missing its
// boundaries, which is the failure mode `recordRun` exists to prevent.
// Returns `undefined` (and touches nothing) unless the dial is on.
const recording = this.startRunRecording();
// (helper used in the catch block below — module-private function
// declared at file end via hoisting)
this.answeredAsk = undefined;
this.resumePassedTime = undefined;
const executor = this.createExecutor(engineOptions, runRequest);
this.inFlightRunId = this.currentRunContext.runId;
// One disposition ledger per run (9.60.0) — registration mirrors what
// this agent's configuration makes applicable; dev posture runs the
// canaries here, BEFORE any real work, so a dead check is named first.
this.beginIntegrityLedger();
// Auto-checkpoint at iteration boundaries — captures the latest
// conversation history into a per-run tracker. On error, we
// wrap the underlying error in `RunCheckpointError` carrying
// this checkpoint so `agent.resumeOnError(checkpoint)` can
// continue from the last good iteration.
const tracker = {
runId: this.currentRunContext?.runId ?? 'unknown',
originalInput: { message: runInput.message },
history: [],
lastCompletedIteration: 0,
};
this.checkpointTracker = tracker;
const stopTracking = this.installCheckpointTracker(tracker);
// The answer this run produces is the only place the final assistant turn
// exists (nothing writes it back into `scope.history`), so `checkpoint()`
// keeps it. Cleared here so a failed or paused run cannot hand back the
// previous run's answer.
this.lastRunAnswer = undefined;
// One run can never raise on another run's consent block.
this.consentOutstanding.clear();
// Set here, beside the `try` whose `finally` clears it, so no exit path
// can leave it behind for a turn this `run()` did not start.
this.runClockDraft = clockDraft;
// Armed here, beside the `try` whose `finally` releases it: no refused
// call above can leave a deadline running.
const runSignal = runSignalOf(engineOptions);
try {
const result = await executor.run({
input: {
message: runInput.message,
...(this.lastRunIdentity !== undefined && { identity: this.lastRunIdentity }),
// A composed message reaches seed only on an agent that reads it
// (declared sources, or the time layer's reader) — every other run's
// input is the bytes it was.
...(messageFrom === 'composed' &&
(this.argumentSourcesArmed === true || this.timeOptions?.reader !== undefined) && {
messageFrom: 'composed',
}),
},
// Co-engineered boundary (#16): the engine's loop-iteration limit
// (footprintjs 9 default 1000) must never fire BELOW the agent's own
// budget — give it headroom (×2 + 10 covers double-hop loop shapes).
// Consumer-provided options win.
maxIterations: this.maxIterations * 2 + 10,
...(runSignal.options ?? {}),
});
const finalized = this.finalizeResult(executor, result);
if (typeof finalized === 'string')
this.lastRunAnswer = finalized;
this.recordPendingQuestion(finalized);
await this.endRunToolSessions(finalized);
// The answer is FINISHED before this line and cannot be changed by it.
// The liveness theorems (9.60.0, dev posture only) — a run that would
// return green while its registered checkers demonstrably never ran
// fails HERE instead, before the recording is filed. A finished run is
// itself the proof work existed: the loop cannot finish without at
// least one model call.
this.assertIntegrityAlive();
// See `fileRunRecording` for why the write is awaited rather than left
// in flight, and why it can never fail the run.
await this.fileRunRecording(recording, finalized);
return finalized;
}
catch (cause) {
// A THROWN pause is still a pause — see `endRunToolSessions`.
await this.endRunToolSessions(cause);
// Wrap recoverable errors with the last-known-good checkpoint.
// Don't wrap intentional terminal signals — let them propagate as
// their typed shapes so callers can `instanceof` them:
// • PauseSignal — askHuman pause, not a failure
// • PolicyHaltError — policy-driven termination; resuming would
// immediately re-trigger the same halt (the synthetic
// tool_result is already in history)
// • ReliabilityFailFastError — finalizeResult constructs and
// throws this AFTER the chart returns cleanly, so it never
// enters this catch (kept here for documentation only)
const isTerminalTypedError = cause instanceof Error &&
(cause.name === 'PauseSignal' ||
cause instanceof PolicyHaltError ||
cause instanceof ReliabilityFailFastError ||
// A provider that reports no usage will report none on resume
// either — wrapping this in a checkpoint would invite the caller to
// retry into the same wall.
cause instanceof CompactionUnmeasurableError ||
// A forced-output capability refusal is terminal even after a
// correction turn: a checkpoint cannot repair this model contract.
isRequestRefusal(cause) ||
// 8.6.0 — a consent block is answered by a PERSON at the identity
// provider, not by replaying the run. Wrapping it in a crash
// checkpoint would hand the caller a retry handle for a wall, and
// would bury the `authorizationUrl` one `.cause` deep where the
// person who has to click it will not look.
cause instanceof CredentialConsentRequiredError ||
// 9.35.0 — an evidence refusal is a VERDICT, not a crash: the run
// finished, the loop already spent its one revision, and resuming
// would put the same model in front of the same evidence. Wrapping
// it would hand the caller a retry handle for a wall and bury the
// named values one `.cause` deep, where the person deciding what to
// do about them will not look.
cause instanceof UnsupportedValuesError ||
cause instanceof AnswerValidationError ||
// 9.60.0 — a dead checker is a WIRING verdict on this build, not a
// recoverable run state: resuming would run the same dead wiring.
cause.name === 'CheckerDeadError');
if (cause instanceof Error && !isTerminalTypedError && tracker.history.length > 0) {
// Observation beats the heuristic: if a bracket was open, it says
// exactly what the run was doing. `classifyFailurePhase` only decides
// when nothing was — a failure between brackets, where the error's own
// text really is the best evidence available (8.14.0).
const observed = tracker.inFlightPhase;
const checkpoint = buildCheckpoint(tracker, {
iteration: tracker.inFlightIteration ?? tracker.lastCompletedIteration + 1,
phase: observed?.phase ?? classifyFailurePhase(cause),
...(observed !== undefined && { stage: observed.stage }),
},
// Read from the live snapshot rather than the tracker: the tracker
// follows `history` through iteration_end events, and a fold's spans
// are committed state. A crash checkpoint that carried the summary
// in its history but not the span behind it would resume into a
// conversation whose evidence the crash had quietly eaten.
this.foldedSpansOf(this.liveSnapshot()?.sharedState),
// A crash checkpoint is the same conversation carrier as
// `checkpoint()`, so it carries the same two owner facts — otherwise
// resuming after a crash would be the one path that still lost the
// identity, and the memory written after the recovery would land
// where nothing could read it.
this.conversationOwner(),
// …and the same graph cursor (SG-C), from the same snapshot reader —
// one reader, two carriers, so neither can lose what the other keeps.
this.continuityCursorOf(this.liveSnapshot()?.sharedState), this.evidenceRecoveryOf(this.liveSnapshot()?.sharedState),
// …and the findings ledger (9.101.0), from the same snapshot reader
// `checkpoint()` uses — one reader, two carriers.
this.findingsLedgerOf(this.liveSnapshot()?.sharedState),
// …and the turn the failing run was on (honesty layers), so the
// retry is stamped the same turn — one reader, two carriers.
this.turnNumberOf(this.liveSnapshot()?.sharedState), undefined, this.negativeClaimsConfig === undefined
? undefined
: negativeCheckpointOf(this.liveSnapshot()?.sharedState ?? {}));
throw new RunCheckpointError(cause, checkpoint);
}
throw cause;
}
finally {
// The run's deadline and signal links end with the run (`runSignalOf`).
runSignal.release();
// The time layer's per-run inputs end with the run: a stale draft would
// be stamped by the next seed this agent did not start (a composition
// mounting its chart) as if the app had declared it.
this.runClockDraft = undefined;
this.resumePassedTime = undefined;
// The run's disposition rows, on EVERY path — success, failure, pause —
// and BEFORE the recording stops, so a recording carries its run's
// checker accounting (9.60.0).
this.fileIntegrityDisposition();
// Always released: a recording left subscribed would keep listening
// through the next run and grow a tail nobody reads.
this.noteAdmittedRun();
recording?.stop();
stopTracking();
this.inFlightRunId = undefined;
// `seed` consumes the restored conversation on its way past. A run that
// died BEFORE seed never did, and a history left armed here would be
// picked up by the next run — which would then continue a conversation
// nobody asked it to. One run, one continuation.
this.pendingResumeHistory = undefined;
this.pendingResumeFolded = undefined;
this.pendingResumeFindingsLedger = undefined;
this.pendingResumeSkillCursor = undefined;
this.pendingEvidenceRecovery = undefined;
}
}
/**
* Continue this agent's own last completed conversation.
*
* The one-liner for turn two and after. `run()` is one turn and starts a new
* conversation each time (see {@link Agent.run}); this reads the
* conversation off the last completed run, appends `message` as the next
* user turn, and runs from there — so the model sees what was actually said.
*
* Sugar over `run({ message, continueFrom: this.checkpoint() })` and nothing
* more: one restoration path, so the convenience cannot drift from the
* mechanism. Reach for `run({ continueFrom })` directly when the
* conversation comes from somewhere other than this instance's last run — a
* store, another process, a different machine.
*
* Refuses rather than guessing: {@link NoConversationError} when this agent
* has no completed run to continue (a "follow-up" that quietly became a
* first turn would be exactly the confusion this door exists to remove),
* and — through `run()` — {@link PendingQuestionError} when the last run
* paused to ask a person something, because a pause has its own door:
* `resume(checkpoint, decision)`.
*
* The conversation grows every turn and nothing here trims it; bounding what
* the model is shown is `.window()` / `.compaction()` / `.memory()`, not a
* silent cap on the way through.
*
* @example
* ```ts
* await agent.run({ message: 'Book me a table for two.' });
* await agent.followUp('Make it three.');
* await agent.followUp('And move it to 8pm.');
* ```
*/
async followUp(message, options) {
// Refuse BEFORE the timing guards, so "there is nothing to follow up on"
// is never reported as "a run is in flight" for an agent that has simply
// not run yet.
if (this.liveSnapshot() === undefined || (this.lastInputRefused() && !this.hadAdmittedRun)) {
throw new NoConversationError('Agent.followUp', 'never-run');
}
const conversation = this.checkpoint();
if (conversation === undefined || conversation.history.length === 0) {
throw new NoConversationError('Agent.followUp', this.lastInputRefused() ? 'last-input-refused' : 'last-run-unfinished');
}
return this.run({ message, continueFrom: conversation }, options);
}
/**
* Drop the question this agent's last run paused to ask, on the record.
*
* A paused run is waiting on a person. Sending a different message while one
* is outstanding is refused ({@link PendingQuestionError}) because silently
* discarding a pending question makes a consent gate something any later
* message can walk around. When the question really is being dropped —
* the user changed the subject, the session timed out, the approval is no
* longer wanted — say so with this, and the next `run()` proceeds.
*
* Returns what was dropped (`undefined` when nothing was pending), so a
* caller can log or audit the abandonment rather than perform it blind. It
* does not touch the paused run's checkpoint: if you still hold that, it
* remains resumable.
*/
abandonPause() {
const dropped = this.pendingQuestion;
this.pendingQuestion = undefined;
return dropped;
}
/**
* Whether {@link Agent.selfExplain}'s why-questions have a run to answer
* from right now.
*
* `false` for two different reasons, both honest: this agent was not built
* with `.selfExplain()`, or it was and no turn has completed yet (evidence
* binds at the END of a run, never to the one in flight). Either way there
* is nothing to explain, which is what a caller routing a why-question needs
* to know before it routes.
*
* The model is told the same thing by the same fact — the trace tools answer
* "No completed run is available yet" and the skill body says to say so
* plainly. This is that answer, for the program.
*
* Evidence is kept per conversation, so on an agent serving several sessions
* this answers for the session of the run in flight — or, between runs, of
* the run served last.
*/
canExplain() {
return this.selfExplainBinding?.artifacts !== undefined;
}
/**
* Resume an agent run from a checkpoint produced by a prior
* `RunCheckpointError`. Unlike `agent.resume()` (which takes a
* `FlowchartCheckpoint` from an intentional pause), this takes
* an `AgentRunCheckpoint` (conversation-history snapshot) and
* replays the agent run with that history restored.
*
* The next iteration retries the call that originally failed —
* with the latest provider state (circuit breaker may have
* closed, vendor may have recovered, etc.).
*
* **Resume = REPLAY from the last completed iteration boundary,
* not exact-state restore.** Only the conversation history is
* restored; everything else re-seeds fresh:
*
* - **Tool re-execution / idempotency**: tool side effects from
* the FAILED iteration are not in the checkpoint. The model
* re-decides from the restored history and may re-issue those
* tool calls — they WILL execute again (there is no built-in
* toolCallId dedup). Mutating tools (payments, emails, DB
* writes) must be idempotent — key on stable call content, not
* `ctx.toolCallId` (a re-issued call gets a new id).
* - **Fresh `runId`**: the resumed run's events carry a new
* `runId`; use `checkpoint.runId` to correlate back to the
* failing run.
* - **Iteration counter + budget reset**: the resumed run starts
* at iteration 1 with a full `maxIterations` budget
* (`checkpoint.lastCompletedIteration` is diagnostic only).
* Token/cost accumulators also restart at zero.
*
* @example
* ```ts
* try {
* const result = await agent.run({ message: 'long task' });
* } catch (err) {
* if (err instanceof RunCheckpointError) {
* await checkpointStore.put(sessionId, err.checkpoint);
* // hours / restart later:
* const checkpoint = await checkpointStore.get(sessionId);
* const result = await agent.resumeOnError(checkpoint);
* }
* }
* ```
*/
async resumeOnError(checkpoint, options) {
const cp = validateCheckpoint(checkpoint);
// The timing guards run HERE, not only inside `run()`, because the line
// below writes the side channel: a refusal after that write would leave a
// restored history armed and the NEXT run would silently continue somebody
// else's conversation.
this.assertNotRunning('Agent.resumeOnError');
this.assertNoPendingQuestion('Agent.resumeOnError');
// A request `run()` would refuse is refused HERE, before the side channel
// below is armed — a refusal after it would leave the restored history for
// the next, unrelated run.
requestOf(options, 'Agent.resumeOnError');
// Stash the checkpointed history on the side channel; the seed function
// reads + clears it before scope.history initializes. No message is
// appended — the failing run's message is already the last user turn in
// that history, and adding it again would ask twice.
this.applyContinuation(cp, 'Agent.resumeOnError', false);
return this.run({
message: cp.originalInput.message,
// The conversation's own identity, unless this call named one. Until
// 9.2.0 there was no way to pass either, so a recovered run silently
// re-namespaced its memory under a fresh runId and wrote turn two
// where turn three could not read it.
...(this.identityFor(options, cp) !== undefined && {
identity: this.identityFor(options, cp),
}),
}, options);
}
/**
* Which identity a continued turn runs under: the caller's if they named
* one, otherwise the conversation's own.
*
* @internal
*/
identityFor(options, cp) {
return options?.identity ?? cp.identity;
}
/**
* A run's `time`, read into the turn's clock draft (the time layer) — or
* refused, before the turn starts: `time` on an agent without `.time()` or
* a malformed value. No zone anywhere (the run's, else the builder's) is no
* refusal: the zone is UNKNOWN (`zoneSource: 'unknown'`, G15) — never the
* server's, never guessed; the person is asked it before any time they
* wrote is read. `undefined` on an agent without the layer.
*
* @internal
*/
clockDraftFor(time, runner) {
const passed = this.passedTimeFor(time, runner);
if (this.timeOptions === undefined)
return undefined;
return draftClock(passed, this.timeOptions);
}
/**
* The clock draft seed stamps (the time layer): the one `run()` read — or,
* for a turn this agent's `run()` did not start (its chart mounted in a
* composition, which passes no `time`), the builder's fallback zone with
* the turn's start as `now` (`zoneSource: 'builder'`, `nowSource:
* 'default'` — the same record a `run()` with no `time` files). With no
* fallback zone that turn's zone is unknown, as a `run()`'s is (G15): the
* server's zone is never used.
*
* @internal
*/
seedClockDraft() {
if (this.runClockDraft !== undefined)
return this.runClockDraft;
return draftClock(undefined, this.timeOptions ?? {});
}
/**
* A `time` passed to a run or a resume, read — or refused by name: passed to
* an agent without `.time()` (a door that ignored it would look configured
* and do nothing), or malformed.
*
* @internal
*/
passedTimeFor(time, runner) {
if (time === undefined)
return undefined;
if (this.timeOptions === undefined) {
throw new InvalidRunInputError({
runner,
received: 'time',
hint: 'this agent reads no clock — arm the time layer with .time() to pass time',
});
}
const read = readRunTime(time);
if ('problem' in read) {
throw new InvalidRunInputError({ runner, received: 'a malformed time', hint: read.problem });
}
return read.value;
}
/**
* Install a per-run checkpoint tracker. Listens for the agent's own
* iteration and stream brackets and snapshots the conversation history into
* the tracker. Returns a stop function.
*
* It listens on the REAL-value path (`EventDispatcher · onRealEvent`), never
* on the served one: the crash checkpoint it builds is what
* `resumeOnError` replays to the model, and a resume checkpoint keeps real
* values — a redaction policy that names `history` must not turn the
* conversation a crash resumes into placeholders (`src/redaction/`).
*
* @internal
*/
installCheckpointTracker(tracker) {
return this.dispatcher.onRealEvent((event) => {
switch (event.type) {
case 'agentfootprint.agent.iteration_start': {
const p = event.payload;
if (typeof p?.iterIndex === 'number')
tracker.inFlightIteration = p.iterIndex;
return;
}
// Phase OBSERVATION (8.14.0) — the run's own stream brackets. A crash
// inside one of these is attributable without asking the error to
// describe itself. `'call-llm'` is a literal and `toolName` is a name
// the app declared: no URL, no credential, no payload ever reaches the
// checkpoint.
case 'agentfootprint.stream.llm_start':
tracker.inFlightPhase = { phase: 'llm', stage: 'call-llm' };
return;
case 'agentfootprint.stream.llm_end':
tracker.inFlightPhase = undefined;
return;
// A bracket carrying `notDispatched` (9.113.0) is a call a paused batch
// never dispatched: nothing of it was ever in flight, so it neither
// opens nor clears the phase. Nothing observable depends on this today
// — the settlement emits both halves back to back with no await between
// them (`toolCalls.ts` · `bracketSettled`), so a crash can never land
// inside one — it keeps the phase true to the marker should that ever
// change.
case 'agentfootprint.stream.tool_start': {
const p = event.payload;
if (p?.notDispatched !== undefined)
return;
const name = p?.toolName;
tracker.inFlightPhase = {
phase: 'tool',
stage: typeof name === 'string' ? name : 'tool',
};
return;
}
case 'agentfootprint.stream.tool_end': {
const p = event.payload;
if (p?.notDispatched !== undefined)
return;
tracker.inFlightPhase = undefined;
return;
}
case 'agentfootprint.agent.iteration_end': {
const p = event.payload;
if (typeof p?.iterIndex === 'number')
tracker.lastCompletedIteration = p.iterIndex;
if (Array.isArray(p?.history)) {
tracker.history = p.history;
}
tracker.inFlightIteration = undefined;
tracker.inFlightPhase = undefined;
return;
}
default:
return;
}
});
}
/**
* Continue a run that paused to ask a person something, with their answer.
*
* WHO the resumed run is for is the paused run's caller, read off the
* checkpoint (`agent/callerIdentity.ts`) — never whatever this instance ran
* last; which SESSION, the call's `sessionId`, else the paused run's. A
* checkpoint names who it is for; it is not proof: a host that lets
* checkpoints leave its trust boundary signs them or keeps them
* server-side, and passes the identity it verified as `options.identity`.
*
* @throws ResumeIdentityConflictError when `options.identity` is not the
* paused run's caller identity — before anything runs.
* @throws DecisionRequiredError when a consent gate is answered with
* something other than a decision.
* @throws ResumeDecisionError when the decision does not answer the request
* this run is paused on: it names no request, another request, a call the
* checkpoint no longer holds (refused before anything runs), or a request
* a resume already answered (refused before the tool runs). The tool never
* runs on a refusal.
* @throws RunInFlightError when a run is already in flight.
*
* @example
* ```ts
* const outcome = await agent.run({ message: 'refund me', identity }, { sessionId });
* if (isPaused(outcome)) await agent.resume(outcome.checkpoint, 'yes', { identity, sessionId });
* ```
*/
async resume(checkpoint, input, options) {
// Taken up, before anything can refuse: from here every use of a consent
// decision passes through the one spend a door watching this pause sees
// (`consentRequest.ts` · `watchDecisionUse`) — so a resume that fails
// without reaching it, refused or aborted, used nothing.
noteDecisionTakenUp(pausedRequestIdOf(checkpoint));
// ── A consent gate is answered with a DECISION (8.13.0) ─────────────
// Refused HERE, before the engine is handed the checkpoint, so nothing
// executes and the checkpoint is unchanged — the caller can answer the same
// one and resume again. Before this, a mis-shaped resume silently DECLINED
// and filed `by: 'unknown'`: a consent record naming a person who was never
// asked. Governance never silently invents a decision, for the same reason
// it never silently drops one.
//
// Discriminated by the PAUSE, never by the input: a plain askHuman/pauseHere
// answer is a value (often a string) and must stay accepted, so the only
// sound question is "what was asked?".
assertIdentityShape(options?.identity, 'Agent.resume');
if (options?.redact !== undefined)
assertRedactionPolicy(options.redact, 'Agent.resume');
// The incoming request is the RESUMING caller's — never the paused one's:
// a checkpoint carries no request, and a resume without one serves none.
const resumeRequest = requestOf(options, 'Agent.resume');
// Native guard lands with the new checkpoint protocol, before its runtime
// opt-in is mounted. Until then no Agent is armed to consume this carrier.
requireVerificationOnResume(checkpoint.sharedState ?? {}, false);
if (this.negativeClaimsConfig !== undefined) {
const prior = negativeCheckpointOf(checkpoint.sharedState ?? {});
if (prior === undefined)
throw new TypeError('Negative-claims pause checkpoint is missing its private evidence carrier.');
}
else if (negativeCheckpointOf(checkpoint.sharedState ?? {}) !== undefined) {
throw new TypeError('Resume requires the negative-claims setting used by the paused agent.');
}
// The policy the paused run was covered by, carried in its own state
// (`runRedaction`) — it covers this leg too, joined with any the caller
// adds now. Read, or refused, before anything moves (`resumeRedactionOf`).
const carriedRedaction = resumeRedactionOf(checkpoint, [
redactionDeclaredBy(this),
options?.redact,
conversationRedaction(),
]);
// A resume's `time` (the time layer) is read or refused before anything
// moves; it is never applied — the paused turn's clock is kept, and a
// differing value is recorded by the ToolCalls resume door.
const passedTime = this.passedTimeFor(options?.time, 'Agent.resume');
options = withoutTime(options);
// One run, one identity — refused before anything moves, and before the
// answer is read at all: a caller this run is not for learns nothing about
// what it is waiting on (its gate, its tool, its request). The paused run's
// caller identity is read off the checkpoint (`callerIdentity.ts`); a call
// naming a DIFFERENT one would give the run one person's memory namespace
// and credentials (restored, never re-seeded) and another's `ctx.identity`,
// principal and stored ownership. A checkpoint names who it is for; it is
// not proof — a host that lets checkpoints leave its trust boundary signs
// them or keeps them server-side, and passes the identity it verified here.
const pausedFor = callerIdentityOf(checkpoint.sharedState, {
...(this.lastRunIdentity !== undefined && { named: this.lastRunIdentity }),
minted: this.mintedRunIds,
});
// The identity the run's memory namespace and credentials are restored
// from, WHATEVER its source (a caller's, the session rung, the per-run
// default). One run, one identity: a call that names an identity must
// name exactly this one — an ownerless (derived) pause resumed by a named
// person is refused too, and so is a checkpoint whose own fields disagree
// (a session-rung marker on an identity that carries a person).
const restored = restoredIdentityOf(checkpoint.sharedState);
if (restored === 'malformed' ||
(options?.identity !== undefined &&
(restored === undefined || !sameIdentity(options.identity, restored)))) {
throw new ResumeIdentityConflictError();
}
// …and the decision must be for THIS request and this exact call
// (`core/consentRequest.ts`): refused here, before anything moves, when it
// names no request, another request, a call the checkpoint no longer
// holds, or — for an ask — rules this agent's chain no longer runs. The
// dispatch loop asks again where the decision is used — and spends it
// there, once.
const gate = pauseDemandsDecision(checkpoint.pauseData);
if (gate) {
checkDecisionAgainst(checkpoint, gate, input, this.toolMiddleware.map((link) => link.name),
// A request minted under a ledger that can prove is spent only there.
this.decisionLedger ?? processDecisionLedger());
}
// Typed data collection is not consent. Validate before changing any run state.
const awaitingInput = gate === undefined ? readAwaitingInput(checkpoint.pauseData) : undefined;
let answeredAsk;
if (awaitingInput !== undefined) {
this.assertNotRunning('Agent.resume');
const answered = applyInputResponse(awaitingInput, input, this.timeAnswerContextOf(checkpoint.sharedState));
if ('cancel' in answered) {
throw new TypeError('[input request] Cancel a hosted request through its host, or abandonPause() before starting another run.');
}
if (answered.missing.length > 0) {
const copy = structuredClone(checkpoint);
const pauseData = {
...copy.pauseData,
awaitingInput: answered,
};
const outcome = {
paused: true,
checkpoint: { ...copy, pauseData },
pauseData,
awaitingInput: answered,
};
this.recordPendingQuestion(outcome);
return outcome;
}
answeredAsk = {
id: answered.id,
requestId: answered.requestId,
count: (awaitingInput.repeat?.count ?? 0) + 1,
};
input = {
status: 'input_received',
requestId: answered.requestId,
values: answered.supplied,
origins: answered.origins,
origin: answered.origin,
...(answered.context !== undefined && { context: answered.context }),
};
}
// And the answer must be about the thing that was asked. Checked HERE, at
// the same door and before any state moves, because a resume that has begun
// is a resume that has already used the value.
assertDecisionIsNotStale(checkpoint.pauseData, input);
// The same one-turn-at-a-time guard `run()` carries: a resume writes the
// same per-instance state a run does. Answering the question is what this
// door is FOR, so it never checks `pendingQuestion` — it clears it.
this.assertNotRunning('Agent.resume');
// Settled the moment the answer is handed over, not when the resumed run
// finishes: a resume that then FAILS must not leave the agent refusing
// every later message on behalf of a question that has been answered.
// Kept to put back when the run refuses the answer itself (a replay of a
// request already answered): that resume answered nothing.
const questionBefore = this.pendingQuestion;
this.pendingQuestion = undefined;
// WHO this resumed run is for — set BEFORE the executor exists, because the
// run context's principal (every event's `EventMeta.principal`), a tool's
// `ctx.identity` and `checkpoint()` all read it: the identity the PAUSED
// run's caller named (a named `options.identity` was checked equal to the
// restored one at the top of this method) — never the identity of whatever
// this instance ran last, which on a shared or pooled agent is another
// person's, or nobody's (`callerIdentity.ts`).
this.lastRunIdentity = options?.identity ?? pausedFor;
// …and WHICH session: the call's, else the one the paused run recorded in
// its own state (`runSessionId`, or the session rung's) — one source, no
// instance memory — so a host that resumes without repeating the session
// does not file the resumed turn (its tool teardown, its events, its
// self-explain evidence) as sessionless.
const sessionId = options?.sessionId ?? pausedSessionOf(checkpoint.sharedState);
const sessionResumeOptions = sessionId !== undefined && options?.sessionId === undefined
? { ...options, sessionId }
: options;
// The leg's policy — the carried one, the caller's, and the paused leg's
// marks — comes back from the emitter and goes to THIS leg's executor
// (`createExecutor` → `openRunRedaction`); nothing per-run is held on the instance.
const resumeLeg = this.emitPauseResume(checkpoint, input, unionRedactionPolicies(carriedRedaction, sessionResumeOptions?.redact));
const resumeOptions = resumeLeg === sessionResumeOptions?.redact
? sessionResumeOptions
: { ...sessionResumeOptions, redact: resumeLeg };
// Fresh executor — footprintjs 4.17.0+ seeds the runtime from
// `checkpoint.sharedState` (and nested subflow states) automatically
// on a fresh executor's `resume()`. No need to retain a paused
// executor between run/resume.
// Recorded on the same terms a fresh run is (9.26.0) — and the recording
// covers the RESUMED run, which is what the recorder saw. A turn that
// paused and resumed is two runs, and each mints its own recording when
// (and only when) it completes.
const recording = this.startRunRecording();
this.answeredAsk = answeredAsk;
const executor = this.createExecutor(resumeOptions, resumeRequest);
// The leg's WHOLE policy rides its own state too (`runRedaction`), so a
// later pause carries what this leg was covered by — names the resume
// added included — and a third leg is never less covered than this one.
// `createExecutor` opened this executor's run; one it did not is refused
// before anything is held or runs — a leg whose policy nothing knows must
// not start.
const legCoverage = coverageOfExecutor(executor);
if (legCoverage.state === 'unknown')
throw new ResumeRedactionError('unreadable');
const leg = { policy: policyOfCoverage(legCoverage) };
this.inFlightRunId = this.currentRunContext.runId;
// A resumed turn is two runs, and each keeps its own ledger — exactly
// the recording's terms one comment up.
this.beginIntegrityLedger();
this.lastRunAnswer = undefined;
this.checkpointTracker = {
runId: this.currentRunContext.runId,
originalInput: { message: '' },
history: [],
lastCompletedIteration: 0,
};
// One run can never raise on another run's consent block.
this.consentOutstanding.clear();
// Beside the `try` whose `finally` clears it (the fresh-run path's terms).
this.resumePassedTime = passedTime;
const legCheckpoint = leg.policy === undefined
? checkpoint
: {
...checkpoint,
sharedState: {
...checkpoint.sharedState,
runRedaction: carriedRunPolicy(leg.policy),
},
};
// A resume arms its own run signal and deadline (`runSignalOf`), released in
// the `finally` below.
const runSignal = runSignalOf(resumeOptions);
try {
const result = await executor.resume(legCheckpoint, input, runSignal.options);
const finalized = this.finalizeResult(executor, result);
if (typeof finalized === 'string')
this.lastRunAnswer = finalized;
// The question this resume answered is settled; a resume that paused
// AGAIN has asked a new one, and that one is outstanding from here.
this.recordPendingQuestion(finalized);
await this.endRunToolSessions(finalized);
// Same liveness gate the fresh-run path applies (9.60.0).
this.assertIntegrityAlive();
await this.fileRunRecording(recording, finalized);
return finalized;
}
catch (cause) {
// An answer the run refused ran nothing and answered nothing: the
// question this agent was waiting on (if any) is still outstanding, and
// no tool session ended — a replayed decision must not tear down the
// sandbox a live pause is holding.
if (cause instanceof ResumeDecisionError || cause instanceof DecisionRequiredError) {
this.pendingQuestion = questionBefore;
throw cause;
}
await this.endRunToolSessions(cause);
throw cause;
}
finally {
runSignal.release();
// A resume's passed `time` ends with the resume (the fresh-run path's terms).
this.resumePassedTime = undefined;
// Same terms as the fresh-run path: rows on every exit, before the
// recording stops (9.60.0).
this.fileIntegrityDisposition();
this.noteAdmittedRun();
recording?.stop();
this.inFlightRunId = undefined;
}
}
/**
* Fire `'run'`-scoped tool teardown — IF this run really ended.
*
* **Not on `finally`, and that is the whole point.** `finally` runs on every
* exit including a pause, and a pause exits TWO ways: a returned
* `RunnerPauseOutcome` and a thrown `PauseSignal`. A check-in on a
* code-interpreter call pauses the run so a person can approve the code —
* tearing the sandbox down there destroys the exact state the resume needs,
* and it fails QUIETLY, as a resumed run that "just re-ran everything".
* Both shapes are discriminated here and both are skipped.
*
* An error IS a terminal: the run is over, nobody is coming back, and a
* sandbox held by a run that crashed is the clearest kind of leak. Only a
* pause survives.
*
* Fired for the TURN, not for `currentRunContext.runId` — `resume()` mints a
* fresh run id, so a pause and its resume are one turn across two runs, and
* filtering on the id would leave everything a paused turn opened alive
* forever. Hosted turns are scoped to their session, preserving other pauses. See `ToolSessionTier.fireRun`.
*
* @param outcome what `run()`/`resume()` is about to return, or about to throw.
*/
async endRunToolSessions(outcome) {
if (!this.toolSessionTier)
return;
if (isPaused(outcome))
return;
if (outcome instanceof Error && outcome.name === 'PauseSignal')
return;
if (this.currentRunContext.sessionId !== undefined)
await this.toolSessionTier.fireSessionRuns(this.currentRunContext.sessionId);
else
await this.toolSessionTier.fireRun();
}
/**
* The conversation this agent's LAST completed run leaves behind, packed as
* the same `AgentRunCheckpoint` that `resumeOnError(...)` accepts. Store it,
* hand it back next turn, and the agent continues where it left off — across
* a restart, a deploy, or a different machine.
*
* Returns `undefined` before any run has completed or when the latest run
* was refused by input middleware. A refused input is not an admitted
* conversation. Keep an earlier accepted checkpoint to continue explicitly;
* `followUp()` after input denial raises `NoConversationError`.
*
* **Read from the run's own committed state, not from a second copy.** The
* history comes from the LIVE snapshot's `sharedState.history` — the state
* the run actually committed, never the served record (an agent's `redact`
* keeps the conversation out of `getLastSnapshot()`, never out of the
* conversation it continues) — cloned on the way out so a persistence layer
* can never mutate the live heap. The final assistant turn is appended from the answer
* `run()` returned, because nothing ever writes it back into `history`: the
* loop appends assistant turns only when they carry tool calls, and the turn
* that ends the run carries none. An agent that stored this conversation
* without that append would drop its own reply every turn and answer the next
* one having forgotten what it just said.
* Provider replay evidence and its normalized thinking travel with that
* accepted answer, including a terminal proof with empty text. The answer
* remains the delivered text, including output-policy edits; raw provider
* text is never substituted back. Evidence comes from live state, not the
* redacted observation. Denied, failed and paused runs append no final turn.
*
* Explicit thinking retirements recorded before transport also apply here:
* a failed grouped call cannot restore reasoning omitted from its request.
* This copies no other failed-stage state or tool effects into the history.
* Adds no events, no scope writes and no capture: every recording is
* byte-identical to an agent that never calls this.
*
* After a run that **paused**, this is the conversation as of the pause, with
* no answer appended — a pause is unfinished work, and pause/resume has its
* own carrier (`FlowchartCheckpoint`) that holds engine state this shape
* cannot.
*
* The conversation grows every turn and nothing here trims it. Bounding what
* the model is shown is the memory subsystem's job (`.memory(...)`), not a
* silent cap applied on the way to storage.
*
* @example
* ```ts
* await agent.run({ message: 'Book me a table for two.' });
* const conversation = agent.checkpoint(); // persist anywhere
* // …a restart later, on a fresh Agent:
* await agent.run({ message: 'Make it three.', continueFrom: conversation });
* // `continueFrom` admits the new turn through the 'input' middleware chain.
* // `resumeOnError` rechecks originalInput but replays stored history as-is;
* // it does not rewrite entries manually appended to that history.
* ```
*/
checkpoint() {
const snapshot = this.liveSnapshot();
if (!snapshot)
return undefined;
const state = snapshot.sharedState;
// Admission is decided by seed, before any conversation state is written.
// Do not manufacture a blank replay carrier for an unadmitted attempt.
if (this.lastInputRefused())
return undefined;
const recorded = (state?.history ?? []);
const history = [...recorded];
if (this.lastRunAnswer !== undefined &&
(this.lastRunAnswer.length > 0 || state?.llmLatestReplay !== undefined)) {
history.push({
role: 'assistant',
content: this.lastRunAnswer,
...assistantReplayFields(state ?? {}),
});
}
const folded = this.foldedSpansOf(state);
const owner = this.conversationOwner();
const skillCursor = this.continuityCursorOf(state);
const evidenceRecovery = this.evidenceRecoveryOf(state);
const findingsLedger = this.findingsLedgerOf(state);
const turnNumber = this.turnNumberOf(state);
return {
version: 1,
runId: this.currentRunContext.runId,
history: structuredClone(checkpointReplayHistory(history, this.checkpointTracker)),
lastCompletedIteration: typeof state?.iteration === 'number' ? state.iteration : 0,
originalInput: { message: typeof state?.userMessage === 'string' ? state.userMessage : '' },
checkpointedAt: Date.now(),
// Absent when nothing ever folded — a key that is always there and
// usually empty reads like "no folds were retained", which is a
// different claim from "there were no folds".
...(folded !== undefined && { folded }),
// Who it was for and who ran it (9.2.0) — both absent unless chosen.
...(owner.identity !== undefined && { identity: owner.identity }),
...(owner.agentId !== undefined && { agent: { id: owner.agentId } }),
// Where the graph stood (SG-C) — written ONLY under
// `continuity: 'conversation'`, so every other checkpoint keeps its
// exact byte shape.
...(skillCursor !== undefined && { skillCursor }),
...(evidenceRecovery !== undefined && { evidenceRecovery }),
// The model's standings (9.101.0) — absent unless the run recorded any,
// by the `folded` rule: an optional key, never a format change.
...(findingsLedger !== undefined && { findingsLedger }),
// The turn this history ends on (honesty layers) — absent unless a
// layer is armed, so every other checkpoint keeps its byte shape.
...(turnNumber !== undefined && { turnNumber }),
...(this.negativeClaimsConfig === undefined || state === undefined
? {}
: negativeCheckpointOf(state)),
};
}
/** Called as each run/resume ends: remember a run that got past input admission. */
noteAdmittedRun() {
if (this.liveSnapshot() !== undefined && !this.lastInputRefused()) {
this.hadAdmittedRun = true;
}
}
/**
* Whether the evidence gate's revision calls no tools: a posture that can
* revise (`'guard'`, `'rails'`) under an ENFORCING answer validation. There
* the answer may yet be withheld, so a revision must not run a tool that
* writes or sends again — and the library cannot tell one that only reads.
*/
revisionWithholdsTools() {
return (this.typedNegativeClaimsConfig !== undefined ||
this.evidenceGate?.toolsOffAfterRevision === true);
}
/** Whether input middleware refused the last run's message — the one reading both doors ask. */
lastInputRefused() {
const state = this.liveSnapshot()?.sharedState;
return state?.messageDeniedPhase === 'input';
}
/** Both checkpoint doors keep the repair budget separately from conversation text. */
evidenceRecoveryOf(state) {
if (state?.evidenceRevisionSpent !== true)
return undefined;
const pending = state.evidenceRecovery;
return {
revisionSpent: true,
...(pending !== undefined &&
state.evidenceRecoveryUsed !== true && {
pending: {
instruction: pending.instruction,
...(pending.conclusion !== undefined && { conclusion: pending.conclusion }),
},
}),
};
}
/**
* The graph cursor a conversation carrier stores — the run's final
* committed `currentSkillId`, read from the SNAPSHOT (the state the run
* actually committed; both chart shapes map the advanced cursor onto it
* every iteration), and only when the mounted graph declared
* `continuity: 'conversation'`.
*
* ONE reader for BOTH carriers — `checkpoint()` (the conversation door
* `followUp()` walks through) and the crash checkpoint
* `RunCheckpointError` carries — the `foldedSpansOf`/`conversationOwner`
* rule: a conversation must not keep its place on one path and silently
* lose it on the other.
*
* @internal
*/
continuityCursorOf(state) {
if (this.skillGraphCascade?.continuity !== 'conversation')
return undefined;
const cursor = state?.currentSkillId;
return typeof cursor === 'string' && cursor.length > 0 ? cursor : undefined;
}
/**
* The folded spans this run has committed, cloned on the way out.
*
* One reader for both carriers — `checkpoint()` and the crash checkpoint
* `RunCheckpointError` carries — so a conversation cannot keep its spans on
* one path and silently lose them on the other. The clone is the same
* promise `checkpoint()` makes about history: a persistence layer never gets
* a reference into the live heap.
*
* @internal
*/
foldedSpansOf(state) {
const spans = state?.foldedSpans;
if (spans === undefined || spans.length === 0)
return undefined;
return structuredClone(spans);
}
/**
* What the resume door checks a time field's answer with (the time design
* § 6.2, step T4): the paused turn's clock zone — the kept `clock` row, so
* a resume that passes another `time` changes nothing — and the app's
* catalog overrides. `undefined` without `.time()`: a `format` field is
* then judged for shape, order and offset only, in the default words.
*
* @internal
*/
timeAnswerContextOf(state) {
if (this.timeOptions === undefined)
return undefined;
const ledger = state?.findingsLedger;
const clock = clockOf(Array.isArray(ledger) ? ledger : undefined);
// An unknown zone (G15) judges no wall time: `zone` is then only the UTC spelling.
const zone = clock?.zoneSource === 'unknown' ? undefined : clock?.zone;
const messages = this.timeOptions.messages;
return {
...(zone !== undefined && { zone }),
...(messages !== undefined && { messages }),
};
}
/**
* The findings ledger for a checkpoint (9.101.0) — `foldedSpansOf`'s twin:
* one reader for `checkpoint()` and the crash carrier, `undefined` when the
* run recorded no rows (so the key stays absent), and a detached copy
* otherwise, never a reference into the live heap.
*
* @internal
*/
findingsLedgerOf(state) {
const ledger = state?.findingsLedger;
if (ledger === undefined || ledger.length === 0)
return undefined;
return structuredClone(ledger);
}
/**
* The turn a checkpoint's history ends on — `findingsLedgerOf`'s twin, one
* reader for `checkpoint()` and the crash carrier. Only while an honesty
* layer is armed (the run constant `honestyLayers`, read from the same
* snapshot): that is when every ledger row carries its `turn` and the next
* run's stamp must continue from it (`AgentRunCheckpoint.turnNumber`).
* `undefined` otherwise, so the key stays absent.
*
* @internal
*/
turnNumberOf(state) {
if (state?.honestyLayers === undefined && this.negativeClaimsConfig === undefined)
return undefined;
const turn = state?.turnNumber;
return typeof turn === 'number' && Number.isSafeInteger(turn) && turn >= 1 ? turn : undefined;
}
/**
* The two owner facts every conversation carrier stamps — who the run was
* for, and which agent ran it (9.2.0).
*
* One reader for `checkpoint()` and the crash checkpoint, the same rule
* `foldedSpansOf` follows: a fact kept on one carrier and lost on the other
* is worse than a fact kept on neither. Both are absent unless the caller
* chose them, which is what keeps the fingerprint refusal narrow and the
* default `conversationId` out of storage.
*
* @internal
*/
conversationOwner() {
return {
...(this.lastRunIdentity !== undefined && { identity: this.lastRunIdentity }),
...(this.explicitId !== undefined && { agentId: this.explicitId }),
};
}
/**
* Restore a stored conversation onto the side channel `seed` reads.
*
* THE one restoration path — `run({ continueFrom })` and `resumeOnError()`
* both come through here, so the conversation door and the error door cannot
* disagree about what continuing means. It checks the agent fingerprint,
* restores history + folded spans, and adopts the conversation's identity so
* the continued turn writes its memory where the earlier turns are.
*
* `appendsUserTurn` is the difference between the two callers, and it is the
* whole difference. Continuing a conversation ADDS this turn's user message
* to the stored history; resuming after an error does NOT, because there the
* message is already the last user turn in that history and appending it
* would ask the same question twice.
*
* Only the FLAG is stashed, never the entry (9.112.2). The entry is written
* by `seed.ts · historyForTurn` from the message the `'input'` middleware
* chain let through; an entry built here, before that chain has run, is the
* caller's raw text — which is what a continued turn served the model
* through 9.112.1, while the record said the rewrite had happened.
*
* @internal
*/
applyContinuation(cp, door, appendsUserTurn) {
assertContinuable(cp, this.explicitId, door);
requireVerificationOnResume(cp, false);
if (this.negativeClaimsConfig === undefined && negativeCheckpointOf(cp) !== undefined)
throw new TypeError('Continuation requires the negative-claims setting used by the recording agent.');
this.pendingResumeHistory = {
history: cp.history,
appendsUserTurn,
// The turn the stored history ends on (honesty layers) — seed continues
// the turn stamp from it only while a layer is armed.
...(cp.turnNumber !== undefined && { turn: cp.turnNumber }),
...(this.negativeClaimsConfig === undefined ? {} : { negatives: negativeCheckpointOf(cp) }),
};
// The folded spans beside it. A conversation stored before 8.2 has none,
// and `undefined` is the right answer there — it means "this conversation
// recorded no folds", which is exactly true.
this.pendingResumeFolded = cp.folded;
// The findings ledger beside it (9.101.0). Stashed whether or not THIS
// agent is armed: seed restores it only under `.findings()` (the deps
// gate), and an unarmed continuation consumes and ignores it — exactly
// like a `folded` field on an agent that never folds.
this.pendingResumeFindingsLedger = cp.findingsLedger;
// The conversation's skill cursor (SG-C). Stashed unconditionally —
// whether it is HONORED is seed's `restoreSkillCursor` gate, which reads
// the mounted graph's `continuity` declaration; a checkpoint written by a
// continuity graph and continued on a `'turn'` one is consumed and
// ignored, exactly like a `folded` field on an agent that never folds.
this.pendingResumeSkillCursor = cp.skillCursor;
// Same failed request retains its single repair budget. A new user turn
// receives neither the rejected draft nor a spent retry allowance.
this.pendingEvidenceRecovery =
!appendsUserTurn && cp.evidenceRecovery !== undefined
? structuredClone(cp.evidenceRecovery)
: undefined;
}
/** One turn at a time — see `RunInFlightError`. @internal */
assertNotRunning(door) {
if (this.inFlightRunId !== undefined) {
throw new RunInFlightError(door, this.id, this.inFlightRunId);
}
}
/** A person's unanswered question outranks a new message — see
* `PendingQuestionError`. @internal */
assertNoPendingQuestion(door) {
if (this.pendingQuestion !== undefined) {
throw new PendingQuestionError(door, this.pendingQuestion);
}
}
/**
* Remember (or forget) the question this run ended on.
*
* A paused outcome sets it; anything else clears it, because a run that
* reached an answer has no outstanding question by definition. Reads the
* same `pauseData` fields `standingAgent.describePause` reads — the tool
* name and question the dispatch loop stamped — and invents nothing.
*
* @internal
*/
recordPendingQuestion(outcome) {
if (typeof outcome === 'string') {
this.pendingQuestion = undefined;
return;
}
const data = outcome.pauseData;
this.pendingQuestion = {
...(typeof data?.toolName === 'string' && { toolName: data.toolName }),
...(typeof data?.toolCallId === 'string' && { toolCallId: data.toolCallId }),
...(typeof data?.question === 'string' && { question: data.question }),
};
}
/**
* Hand the `.selfExplain()` binding to the agent that owns it.
*
* Called once by `AgentBuilder.build()`, immediately after `bindTo`. The
* binding stays the tool provider's to read; the Agent holds it only so
* `canExplain()` answers from the same fact the trace tools answer from,
* rather than from a second guess about whether a run has completed.
*
* @internal
*/
bindSelfExplain(binding) {
this.selfExplainBinding = binding;
}
/**
* Refuse, at run start, any declared messages-slot role this provider
* cannot carry inside its message list (7.21, D2).
*
* Run start rather than build time because the answer depends on the
* provider — and a decorated provider (`withFallback`, `withRetry`) is only
* the thing it is once composed. Run start rather than delivery time
* because a declaration that can never work should fail on the first call,
* not three iterations into a paid run. The delivery stage re-checks each
* message anyway, which is what catches roles that only exist at run time
* (a hand-built memory-recall subflow's formatted output).
*
* Called from the ONE place `run()` and `resume()` share, so no entry point
* can slip past it.
*/
/**
* The OPEN skills — the ones `read_skill` may reach from anywhere, whatever the
* graph's cursor says (8.4.0). Two clauses, both load-bearing:
*
* • `trigger.kind === 'llm-activated'` — the trigger that reads
* `activatedInjectionIds`, which is the ONLY thing a `read_skill` call writes.
* It is exactly "read_skill can really activate this", so admitting anything
* else (a hand-built `rule` injection, say) would replace one lie with another:
* the tool would answer "activated" and nothing would activate.
* • the graph declares no incoming edge to it — a bare model edge `.route(a, m)`
* is a declared, drawn, `from`-gated affordance ("from a, the model may hop to
* m"), and opening every such target would silently globalize it. What is left
* is skills the graph never mentions at all.
*
* That covers three shapes that were all dead before: `.selfExplain()`'s debug
* skill under a graph, a `.skill()`/`.skills()` registration beside a graph, and a
* skill listed in `skills[]` and wired to nothing (whose own check-up warning says
* "it can only be reached by the model via read_skill" — true again now).
*
* An open pick ACTIVATES but never moves the cursor — see the tool-calls gate.
* Computed once per chart build; the injection list is fixed at construction.
*/
/**
* The identity facts this run hands `tool.execute` (9.7.0).
*
* Read through an ACCESSOR from the chart (see `ToolCallsHandlerDeps.currentRun`)
* because the chart is built once and this changes every run.
*
* `identity` is `lastRunIdentity` — what the CALLER passed — and deliberately
* NOT `scope.runIdentity`, which is always populated and defaults to
* `{ conversationId: '<runId>' }` (or, on a session-bound run since 9.10.0,
* to `{ conversationId: sessionId }`). Handing a tool a synthesized
* conversation as "the identity" would let it key an isolated session on a
* fiction, and would make "absent" unrepresentable at exactly the layer that
* most needs to see it. The session-derived namespace is synthesized too, and
* is withheld here for that reason — `sessionId` beside it is the fact the
* transport really delivered.
*/
toolRunFacts() {
return {
runContext: { ...this.currentRunContext },
runId: this.currentRunContext.runId,
...(this.currentRunContext.sessionId !== undefined && {
sessionId: this.currentRunContext.sessionId,
}),
...(this.lastRunIdentity !== undefined && { identity: this.lastRunIdentity }),
};
}
/**
* The teardown tier, built on FIRST registration.
*
* An agent whose tools never hold a session never allocates one, and its
* terminals stay a single `undefined` check.
*/
toolSessions() {
if (!this.toolSessionTier) {
this.toolSessionTier = new ToolSessionTier({
timeoutMs: this.toolTeardownTimeoutMs,
report: (report, origin) => this.emitToolSessionReport(report, origin),
});
}
return this.toolSessionTier;
}
/**
* Turn one TEARDOWN report into a typed `agentfootprint.tools.session_*` event.
*
* Only the two closing events come through here. A start and a reuse happen
* inside `tool.execute`, where the dispatch loop still holds the scope, so
* those ride the ordinary emit channel and carry the stage they really
* happened in. These two fire after the run's last stage committed, and this
* is the one place that has to answer "from where?" without a stage to point
* at.
*
* **Built with `buildEventMeta`, never `minimalMeta()`.** `minimalMeta()`
* hardcodes `runId: 'consumer-scope'`, and a teardown event stamped that way
* cannot be joined to the run that OPENED the session — the exact
* unjoinability 9.4.0 spent a release fixing for credential events. So the
* meta comes from the registration-time run context, with a STATED pseudo-stage, the same
* move as the `'<stageId>#paused'` stamp at the pause boundary.
*/
emitLateFact(type, payload, runContext) {
const dispatcher = this.getDispatcher();
if (!dispatcher.hasListenersFor(type))
return;
dispatcher.dispatch({
type,
payload,
meta: buildEventMeta({ runtimeStageId: LATE_ARTIFACT_FACT_STAGE_ID }, runContext),
});
}
emitToolSessionReport(report, origin) {
const type = report.kind === 'closed'
? 'agentfootprint.tools.session_closed'
: 'agentfootprint.tools.session_close_failed';
const dispatcher = this.getDispatcher();
if (!dispatcher.hasListenersFor(type))
return;
const { kind: _kind, ...payload } = report;
dispatcher.dispatch({
type,
payload,
meta: buildEventMeta({ runtimeStageId: TOOL_TEARDOWN_STAGE_ID }, origin.runContext ?? this.currentRunContext),
});
}
openSkillIds() {
return this.injections
.filter((i) => i.flavor === 'skill' &&
i.trigger.kind === 'llm-activated' &&
!this.skillGraphEdgeTargets.has(i.id))
.map((i) => i.id);
}
// FOLD (resolver) · assembles the ReadSkillOffer the read_skill DESCRIPTION (a Lens) is built from:
// grantable ← makeReachableSkills ∪ openSkillIds, cursor ← scope, hidden ← hiddenSkillIdsNow.
// It assembles; it never composes a sentence. The sentence is skillToolDescriptors.describeOffer.
/**
* The per-iteration `read_skill` offer builder — or `undefined` to leave the tool
* exactly as it has always been (8.5.0).
*
* `read_skill` enumerated every registered skill while the gate admitted only
* `reachableSkills(cursor) ∪ open`, so under a graph the model was handed ids it
* would be refused, every iteration, and could spend a whole run re-asking. The
* OFFER is rebuilt here from the same two functions the gate itself calls — one
* source of truth, so the menu cannot drift from the verdict.
*
* Two guards:
*
* • no graph AND no per-role skill visibility → `undefined`. A plain
* `read_skill` agent has no cursor and no gate; every registered skill
* really is reachable, and the tool keeps its byte-identical description.
* • `reactMode: 'classic'` → `undefined` for the GRAPH menu, plus a dev-mode
* warning. Classic composes the tools slot on turn 1 ONLY (see the Context
* selector's `includeStatic`), so a cursor-scoped menu would freeze at the
* cold-start cursor and keep advertising it for the rest of the run — a
* worse lie than the honest full catalog. `.selfExplain()` refuses under
* classic for exactly this caching reason; here the full catalog is a
* correct fallback, so this warns instead of refusing.
*
* Per-role VISIBILITY (9.11.0) survives classic, and that is not an
* inconsistency: a cursor moves every iteration, but who is asking does not
* change inside one run. A filter computed on turn 1 is still exactly right
* on turn 9.
*/
readSkillOfferFor() {
const skills = this.injections.filter((i) => i.flavor === 'skill');
if (skills.length === 0)
return undefined;
const filtersByRole = this.governsSkillVisibility();
if (!this.skillGraphReachable && !filtersByRole)
return undefined;
let graphMenu = this.skillGraphReachable;
if (graphMenu && this.reactMode === 'classic') {
if (isDevMode()) {
// eslint-disable-next-line no-console
console.warn("agentfootprint Agent: read_skill's menu is built at turn 1 under " +
"reactMode: 'classic', which caches the tools slot — so it lists every " +
'registered skill instead of the ones reachable from the cursor, and the ' +
"model will be offered ids the graph will refuse. Use the default 'dynamic' " +
"mode (or 'dynamic-grouped') for a cursor-tracking menu.");
}
graphMenu = undefined;
if (!filtersByRole)
return undefined;
}
const open = this.openSkillIds();
const reachable = graphMenu;
// A decision `tree()` keeps no cursor, so every routing pick is refused by
// construction (9.86.0). The descriptor withholds the tool entirely when
// nothing is left to open, and explains the tree when something is.
const treeRouted = reachable !== undefined && this.skillGraphIsTree;
return (args) => {
const grantable = reachable
? [...new Set([...reachable(args.currentSkillId), ...open])]
: undefined;
// `undefined` when the offer is WITHHELD — the tools slot then drops the
// schema from the request. (`skills` is non-empty, so an empty catalog is
// not one of the ways this can be undefined.)
return buildReadSkillTool(skills, {
...(grantable !== undefined && { grantable }),
...(args.hiddenSkillIds !== undefined && { hiddenIds: args.hiddenSkillIds }),
// WHERE THE MODEL STANDS (9.84.0) — unconditional, unlike the menu below.
// The description names it, and stops listing it as unreachable.
...(args.currentSkillId !== undefined && { cursorId: args.currentSkillId }),
// The turn-start menu (SG-C) — the tools slot passes it only while the
// verdict is outstanding; describeOffer leads with it.
...(args.menu !== undefined && { menu: args.menu }),
...(treeRouted && { treeRouted: true }),
})?.schema;
};
}
/**
* Does the configured checker ask to decide which skills this caller sees?
* (9.11.0)
*
* Absence is NO — see `PermissionChecker.governs`. This is the ONE switch:
* false and the menu, the resolver and the activation gate are all inert, so
* an agent with a checker that predates 9.11.0 composes the same prompt it
* always did.
*/
governsSkillVisibility() {
return checkerGoverns(this.permissionChecker, 'skill_read');
}
// FOLD (resolver) · computes "which skill ids may this caller be told about".
// The OWNER of the published fact is src/injection-engine/buildInjectionEngineSubflow.ts · hiddenSkillIdsOf
// (the engine's Gather stage), which resolves it once per iteration, before the cursor and the
// evaluator decide, and publishes it on scope; every model-facing composer reads THAT.
// Second caller: the turn router (stages/routeTurn.ts · resolveHidden) resolves it again, with different
// failure semantics, and publishes nothing — two sweeps of one policy question per turn.
/**
* Which skills the caller's role may NOT see, asked once per iteration
* (9.11.0).
*
* Per iteration rather than per run because the checker is a port: a
* hub-backed one can legitimately answer differently as a grant is revoked
* mid-conversation, and caching the first answer would keep a withdrawn skill
* on the menu for the rest of the run. The cost is one `check()` per skill per
* iteration, paid only by agents that opted in.
*
* A skill is hidden when the checker returns anything other than `'allow'` /
* `'gate_open'` — and when the checker THROWS, which is the fail-closed half:
* a policy that did not answer did not say yes, and the same sentence governs
* the tool gate.
*/
hiddenSkillIdsNow() {
if (!this.governsSkillVisibility())
return undefined;
const checker = this.permissionChecker;
const skillIds = this.injections.filter((i) => i.flavor === 'skill').map((i) => i.id);
if (!checker || skillIds.length === 0)
return undefined;
return async (scope) => {
// The caller of THIS run: the instance's caller on its own run, the
// member's run (never the instance's last caller) on a mount.
const identity = this.ownScopes.has(scope) ? this.lastRunIdentity : memberIdentityIn(scope);
const hidden = [];
for (const id of skillIds) {
let allowed = false;
try {
const decision = await checker.check({
capability: 'skill_read',
actor: 'agent',
target: skillTarget(id),
...(identity !== undefined && { identity }),
});
allowed = decision.result === 'allow' || decision.result === 'gate_open';
}
catch {
// Fail closed. The refusal the model would read is the dispatch
// gate's job; here the only decision is whether to advertise a skill
// whose policy is unreachable, and advertising it would be a menu
// built on a question nobody answered.
allowed = false;
}
if (!allowed)
hidden.push(id);
}
return hidden;
};
}
assertDeliverableRoles() {
const providers = providerTargets(this.provider, this.model, this.reliabilityConfig?.providers).map((target) => target.provider);
for (const inj of this.injections) {
for (const msg of inj.inject?.messages ?? []) {
assertDeliverableRole(providers, msg.role, `Agent injection '${inj.id}'`);
// Content, judged at the same funnel as role (8.18.0). The named
// factories refuse empty content; a hand-built Injection reached the
// delivery stage unchecked and put a contentless turn on the wire.
const content = msg.content;
if (typeof content !== 'string' || content.trim() === '') {
throw new Error(messagesContentRefusal({
site: `Agent injection '${inj.id}'`,
role: msg.role,
received: typeof content !== 'string'
? content === undefined
? 'missing'
: `a ${content === null ? 'null' : typeof content}`
: 'empty',
}));
}
}
}
}
/**
* Refuse, at run start, a `'tool-forced'` output strategy on a provider
* that does not put a forced tool choice on its wire (7.26).
*
* Run start rather than build time for the reason `assertDeliverableRoles`
* gives one method up: a decorated provider (`withFallback`, `withRetry`,
* a breaker) is only the thing it is once composed, and `withFallback`
* publishes the AND of its pair.
*
* Refusal rather than a quiet fall back to `'instruct'`, because the two
* are not interchangeable: one constrains the shape at generation, the
* other asks for it in prose. An agent that silently got the second while
* its config said the first would be a promise the recording could not
* even show was broken.
*/
assertForcedToolChoiceSupported() {
if (this.outputEnforcement?.schemaTool === undefined)
return;
const first = this.reliabilityConfig?.providers?.[0];
const provider = first?.provider ?? this.provider;
const model = first?.model ?? this.model;
if (provider.carriesForcedToolChoice === true) {
// Configuration resolves the actual model at seed, after this check.
// Keep the early wire refusal, but let callLLM judge that final model.
if ((first === undefined && this.runConfigFn !== undefined) ||
forcedToolChoiceFor(provider, model))
return;
throw new UnsupportedToolChoiceError({ provider: provider.name, model });
}
throw new Error(`Agent: .outputSchema(parser, { strategy: 'tool-forced' }) needs a provider that puts ` +
`a forced tool choice on its wire, and '${provider.name}' does not declare one. ` +
`Anthropic, Bedrock, real OpenAI/Azure and the mock do; an OpenAI-COMPATIBLE endpoint ` +
`behind a custom baseURL (Ollama, vLLM, …) deliberately does not, because what that ` +
`server does with tool_choice is not this library's to promise. Use ` +
`{ strategy: 'instruct', retries: N } on this provider — it works on every wire — or ` +
`run the forced strategy on a provider that declares the capability.`);
}
/**
* Refuse, at run start, a `'native'` output strategy on a provider that
* does not put a structured-output format on its wire, or on a model that
* does not take one — the `assertForcedToolChoiceSupported` twin, for the
* same reasons: run start because a decorated provider is only what it is
* once composed; a refusal because `'instruct'` asks in prose for what this
* strategy constrains at decoding, and a quiet swap would be config that
* lies.
*/
assertNativeOutputSchemaSupported() {
if (this.outputEnforcement?.outputFormat === undefined ||
this.outputEnforcement.nativeOutputAuto === true)
return;
const first = this.reliabilityConfig?.providers?.[0];
const provider = first?.provider ?? this.provider;
const model = first?.model ?? this.model;
if (provider.carriesNativeOutputSchema === true) {
// A configured agent resolves its model at seed; callLLM judges it there.
if ((first === undefined && this.runConfigFn !== undefined) ||
nativeOutputSchemaFor(provider, model))
return;
throw new UnsupportedNativeOutputError({ provider: provider.name, model });
}
throw new Error(`Agent: .outputSchema(parser, { strategy: 'native' }) needs a provider that puts a ` +
`structured-output format on its wire, and '${provider.name}' does not declare one. ` +
`anthropic(), browserAnthropic() and the mock do; the Bedrock and gateway adapters do ` +
`not in this release. Use { strategy: 'instruct', retries: N } on this provider — it ` +
`works on every wire — or run the native strategy on a provider that declares it.`);
}
createExecutor(runOptions, request) {
this.assertDeliverableRoles();
this.assertForcedToolChoiceSupported();
this.assertNativeOutputSchemaSupported();
const correlationId = runOptions?.correlationId;
const traceId = runOptions?.traceId ?? runOptions?.env?.traceId;
const sessionId = runOptions?.sessionId;
// The actor, for every event this run emits (9.11.0).
//
// `lastRunIdentity` is what the CALLER passed and nothing else — `run()`
// sets it from `input.identity ?? options.identity ?? the conversation's`,
// and `resume()` from `options.identity ?? the paused run's caller's`
// (`agent/callerIdentity.ts`), both before this method is reached; it stays
// undefined when nobody named one.
//
// NOT `scope.runIdentity`: that one is always populated and defaults to
// `{ conversationId: '<runId>' }` (or, since 9.10.0, to the sessionId on a
// session-bound run). Stamping either as the principal would publish a
// synthesized conversation as an actor — and a caller-supplied session id
// is exactly the string an auditor must not read as "who did this".
// `conversationId` is deliberately not carried: it is a thread, not a
// person, and `sessionId` beside it is the fact the transport delivered.
const actor = runOptions?.identity ?? this.lastRunIdentity;
// A fresh run starts with no evictions filed — the previous run's last
// visit must not be read as this run's (`window/evictedTurns.ts`).
this.evictedTurnsHandle?.clear();
const runId = makeRunId();
this.mintedRunIds.add(runId);
boundInsertionOrder(this.mintedRunIds, MAX_REMEMBERED_RUNS);
const hosted = hostedConversationOf(runOptions);
this.servingConversation =
sessionId !== undefined
? sessionConversationKey(sessionId)
: hosted !== undefined
? hostedConversationKey(hosted)
: undefined;
this.currentRunContext = {
runStartMs: Date.now(),
runId,
compositionPath: [`Agent:${this.id}`],
...(correlationId !== undefined && { correlationId }),
...(traceId !== undefined && { traceId }),
// Session identity rides beside runId, not instead of it: one session
// produces many runs, and an event needs to say which of each it is.
...(sessionId !== undefined && { sessionId }),
...(actor?.principal !== undefined && { principal: actor.principal }),
...(actor?.tenant !== undefined && { tenant: actor.tenant }),
};
const getRunCtx = () => this.currentRunContext;
// The run's redaction (`src/redaction/`): this agent's `redact` joined
// with any policy the caller handed down for this run (a tool's
// `ctx.redact`). Opened for EVERY run — with no policy the serving is the
// identity, but the stages' scopes still feed the real-value path the
// crash checkpoint and the window's meter read.
const redaction = this.openRunRedaction(runOptions?.redact, getRunCtx);
// Reuse the cached chart built at constructor time.
// The Agent's executor dials: readTracking (#18/#14, snapshot stageReads),
// commitValues (#13c-B, commit-log value encoding) and writeProvenance
// (#P1, per-write read provenance — the exact-dataflow debugging dial).
const spec = this.getSpec();
// Every scope THIS executor makes is marked as the agent's own, so a stage
// can tell its own run from a mount in another runner's executor
// (`memberRun.ts`) — per scope, never by instance state.
const scopeFactory = redaction.scopeFactoryFor(spec);
const ownScopes = this.ownScopes;
const scopeRequests = this.scopeRequests;
const executor = new FlowChartExecutor(spec, {
readTracking: this.readTracking,
commitValues: this.commitValues,
writeProvenance: this.writeProvenance,
scopeFactory: (...args) => {
const scope = scopeFactory(...args);
if (scope !== null && typeof scope === 'object') {
ownScopes.add(scope);
// THIS executor's request, bound to the scope for its whole life.
if (request !== undefined)
scopeRequests.set(scope, request);
}
return scope;
},
});
redaction.applyTo(executor);
// Enable structured narrative so `getLastNarrativeEntries()` can
// hand a populated array to consumer Trace views (ExplainableShell).
// Cheap when no consumer reads it; the recorder accumulates only.
executor.enableNarrative();
this.lastExecutor = executor;
const dispatcher = this.getDispatcher();
// RFC-001 Block 10 — observer delivery tier. With 'deferred', every
// bridge below is attached onto footprintjs's bounded capture queue
// ("one beat behind": capture inline ≈ microseconds, delivery at the
// next microtask checkpoint, terminal flush before run()/resume()
// returns). Default 'inline' attaches with no options bag —
// byte-identical to every prior release.
const deferredOpts = this.observerDelivery === 'deferred'
? { delivery: 'deferred', ...this.observerDeliveryOptions }
: undefined;
const attachObserver = (rec) => {
if (deferredOpts)
executor.attachCombinedRecorder(rec, deferredOpts);
else
executor.attachCombinedRecorder(rec);
};
attachObserver(new ContextRecorder({ dispatcher, getRunContext: getRunCtx, realWrites: redaction }));
// Evidence bridge (#5): harvest decisions/toolCalls/tokens for causal snapshots.
// ALWAYS INLINE — never routed through the deferred queue: the memory
// write stage consumes its accumulators MID-run (`collect()` via
// `evidenceSource`, mountMemoryPipeline). Deferred delivery would run
// `collect()` before the queue flushed the turn's tool/token/decision
// events, persisting an incomplete causal snapshot.
if (this.causalEvidence) {
// Its emits arrive on the real-value path (constructor); the executor
// feeds it the flow channel's decisions and the per-run reset only.
const evidence = this.causalEvidence;
executor.attachCombinedRecorder({
id: evidence.id,
onDecision: (event) => evidence.onDecision(event),
onSelected: (event) => evidence.onSelected(event),
clear: () => evidence.clear(),
});
}
// Compaction's instrument. ALWAYS INLINE, for the same reason the evidence
// bridge is: the compaction stage reads it MID-run, at the loop head, to
// decide whether this iteration's window is over budget. A measurement
// delivered one beat behind would be a measurement of the wrong window.
if (this.compactionMeterHandle) {
executor.attachCombinedRecorder(this.compactionMeterHandle);
// Folds speak the context vocabulary consumers already subscribe to
// (`context.evicted` / `context.budget_pressure`) — no new event types.
// ContextRecorder only dispatches those from writes INSIDE a slot
// subflow, and the compaction stage is not one, so it emits them and
// this bridge forwards them (the `contextEvaluatedRecorder` pattern).
attachObserver(new EmitBridge({
dispatcher,
id: 'af-compaction-events',
prefix: ['agentfootprint.context.evicted', 'agentfootprint.context.budget_pressure'],
getRunContext: getRunCtx,
}));
}
// The InjectionEngine typedEmits context.evaluated; this bridge forwards it
// to the dispatcher (ContextRecorder handles the write-derived context.*).
attachObserver(contextEvaluatedRecorder({ dispatcher, getRunContext: getRunCtx }));
attachObserver(streamRecorder({ dispatcher, getRunContext: getRunCtx }));
// agentRecorder feeds the run-checkpoint tracker (iteration_end →
// history snapshot), which is read ONLY in run()'s catch — after the
// engine's terminal flush at the reject boundary — so deferral is safe
// (pinned by test: crash checkpoints stay complete under 'deferred').
attachObserver(agentRecorder({ dispatcher, getRunContext: getRunCtx }));
// Terminal-failure bridge: footprintjs onRunFailed → typed error.fatal,
// so a thrown run clears in-flight live state + flips monitor status.
// Deferral-safe: the reject-boundary terminal flush delivers error.fatal
// before the rejection reaches the caller.
attachObserver(errorBridge({ dispatcher, getRunContext: getRunCtx }));
// The cost bridge is ALWAYS attached since 8.14.0. It used to be gated on
// `pricingTable`, which was right while `cost.*` only ever meant money:
// `emitCostTick` returns on its first line without a table, so the gate
// could not hide anything. `cost.limit_hit { kind: 'max_iterations' }`
// broke that assumption — an iteration limit has no price and fires on any
// agent, and behind the old gate it would have reached the dispatcher only
// for agents that happened to be costing themselves. The bridge drops
// events with no listener, so an agent that subscribes to nothing pays
// nothing for it.
attachObserver(costRecorder({ dispatcher, getRunContext: getRunCtx }));
if (this.permissionChecker) {
attachObserver(permissionRecorder({ dispatcher, getRunContext: getRunCtx }));
}
// Always-on bridges for consumer-emitted domain events.
attachObserver(evalRecorder({ dispatcher, getRunContext: getRunCtx }));
attachObserver(memoryRecorder({ dispatcher, getRunContext: getRunCtx }));
// Embedding cost telemetry (8.9.0). The domain had a payload, a registry
// entry and a DomainWildcard arm since 2.x and NO bridge, so the event
// could not have arrived however correctly it was fired. Zero-cost when
// the agent embeds nothing.
attachObserver(embeddingRecorder({ dispatcher, getRunContext: getRunCtx }));
attachObserver(skillRecorder({ dispatcher, getRunContext: getRunCtx }));
attachObserver(toolsRecorder({ dispatcher, getRunContext: getRunCtx }));
// Tool-args validation events (#9) — always-on; zero-cost when no
// validation event fires.
attachObserver(validationRecorder({ dispatcher, getRunContext: getRunCtx }));
// Credential lifecycle (9.4.0). The domain has emitted since 6.11.0 with no
// bridge, so `agent.on('agentfootprint.credential.failed', …)` observed
// nothing however correctly the event fired — which is how an identity
// adapter that failed every call did so in a silence that read like health.
// Always-on and zero-cost: the bridge drops an event nobody listens for.
attachObserver(credentialRecorder({ dispatcher, getRunContext: getRunCtx }));
// Map engagement (9.58.0). Shipped WITH the domain, same lesson as above.
attachObserver(mapRecorder({ dispatcher, getRunContext: getRunCtx }));
// Context Integrity findings (9.60.0). Same lesson, applied again.
attachObserver(integrityRecorder({ dispatcher, getRunContext: getRunCtx }));
// Artifact lifecycle (9.21.0). Shipped WITH the domain — the credential
// bridge above is the record of what waiting costs. Always-on and
// zero-cost: with no store attached nothing emits, and the bridge drops
// an event nobody listens for.
attachObserver(artifactsRecorder({ dispatcher, getRunContext: getRunCtx }));
// Reliability telemetry (rules-loop fail_fast / retried / recovered).
// Always-on, but zero-cost when no .reliability() config fires events.
attachObserver(reliabilityRecorder({ dispatcher, getRunContext: getRunCtx }));
// Provider-decorator telemetry (withFallback / withRetry reports,
// translated in-run by `resilienceHooks`). Always-on, but zero-cost
// when the configured provider is undecorated or never fails. Also
// covers a `.reliability()` run: that path is `executeWithReliability`
// driving `singleProviderCall` inside the SAME call-llm stage
// (callLLM.ts), so the decorator's reports ride this bridge too. (It
// is NOT the `buildReliabilityGateChart` subflow — the Agent never
// mounts that chart; nothing shipped does.)
attachObserver(resilienceRecorder({ dispatcher, getRunContext: getRunCtx }));
// Check-in events bridge (evidence-carrying human consent). Always-on;
// forwards checkin.request/decision $emits to the dispatcher so
// `agent.on('agentfootprint.checkin.*')` fires. Zero-cost until a tool with
// `checkIn` trips.
attachObserver(checkInEventsBridge({ dispatcher, getRunContext: getRunCtx }));
// Same wiring for `agentfootprint.middleware.*` — the governance chains'
// one event. Attached only when a chain exists: an agent without middleware
// gains no bridge, no listener and no per-event work.
if (this.toolMiddleware.length > 0 || this.messageMiddleware.length > 0) {
attachObserver(new EmitBridge({
id: 'agentfootprint.middleware-bridge',
prefix: 'agentfootprint.middleware.',
dispatcher,
getRunContext: getRunCtx,
}));
}
// Same wiring for `agentfootprint.findings.*` (9.101.0) — the ledger's
// events, filed by `recordFindings` on scope. An unarmed agent gains
// no bridge, no listener and no
// per-event work, and `agent.on('agentfootprint.findings.*')` can only
// ever fire on an agent that could have filed a row.
// Also under the inputs layer (honesty layer 2): its `argument` rows are
// filed through the same writer and ride the same domain — and under the
// results layer (honesty layer 3), whose `period` rows do the same.
// Negative claims also file prose warnings, independently of those layers.
if (this.findingsOptions !== undefined ||
this.inputsLayerArmed ||
this.resultsLayerArmed ||
this.negativeClaimsConfig !== undefined) {
attachObserver(new EmitBridge({
id: 'agentfootprint.findings-bridge',
prefix: 'agentfootprint.findings.',
dispatcher,
getRunContext: getRunCtx,
}));
}
// Same wiring for `agentfootprint.answer.*` (honesty layer 4) — the one
// event the answer layer's stage fires per answer. Attached only under
// `.answerLayer()`, shipped WITH the domain (the credential.* lesson).
if (this.answerLayerOption !== undefined) {
attachObserver(new EmitBridge({
id: 'agentfootprint.answer-bridge',
prefix: 'agentfootprint.answer.',
dispatcher,
getRunContext: getRunCtx,
}));
}
// Same wiring for `agentfootprint.tool_choice.*` (9.105.0) — the three
// events `recordToolChoice` files on scope. Attached only under
// `.toolChoice()`, for the same reason.
if (this.toolChoiceOptions !== undefined) {
attachObserver(new EmitBridge({
id: 'agentfootprint.tool-choice-bridge',
prefix: 'agentfootprint.tool_choice.',
dispatcher,
getRunContext: getRunCtx,
}));
}
// Same wiring for `agentfootprint.ontology.*` (9.106.0) — the one event
// `callLLM` emits when the declared map is served. Attached only under
// `.ontology()`, for the same reason.
if (this.ontology !== undefined) {
attachObserver(new EmitBridge({
id: 'agentfootprint.ontology-bridge',
prefix: 'agentfootprint.ontology.',
dispatcher,
getRunContext: getRunCtx,
}));
}
for (const r of this.attachedRecorders) {
// A recorder's OWN `delivery` field is more specific than the
// agent-level default — footprintjs's options bag would override the
// field, so recorders that declare a tier are attached bare (their
// field rules). This gives consumers a per-recorder escape hatch:
// `{ id, delivery: 'inline', ...hooks }` stays inline under an
// observerDelivery: 'deferred' agent, and vice versa.
if (r.delivery !== undefined)
executor.attachCombinedRecorder(r);
else
attachObserver(r);
}
// LAST, so the manifest describes a fully-wired run — and so it is the
// first event of this runId that any listener sees.
this.emitRunManifest();
// …then the declared skill map (9.50.0), so a recording opens with the
// arm's configuration and the author's topology before any stage event.
this.emitSkillGraphDeclared();
return executor;
}
/**
* File the run-configuration manifest (9.41.0) — which adapters and
* strategies this run is about to use, stamped with the runId every other
* event of the run already carries.
*
* **Why it lives in `createExecutor` and not in `run()`.** `run()` and
* `resume()` both come through here, and both mint a fresh runId (a resumed
* run is a new run to every consumer joining on `meta.runId`, so a resume
* with no manifest would be a run whose arm nobody can name). One funnel is
* also how the next entry point cannot forget — the `beginIngress` lesson.
*
* **Why a direct dispatch rather than `typedEmit`.** There is no stage: the
* chart has not started. So it is built with `buildEventMeta` and a STATED
* pseudo-stage, exactly like the tool-teardown reports at the other end of
* the run — never `minimalMeta()`, whose hardcoded `runId: 'consumer-scope'`
* would make the one event whose whole job is to BE joinable the one event
* that cannot be joined.
*
* **Why it is gated on a listener.** Every typed event in this library is:
* the dispatcher drops what nobody subscribed to, and `EmitBridge` does the
* same upstream. The gate is what keeps an unwatched agent at one map
* lookup per run. It also means the manifest is not "always on" but "always
* there when anything is watching" — including `recordRun`, which subscribes
* with `'*'` before the run starts, so every recording carries one.
*/
/** Fresh per-run ledger — see AgentOptions.integrityPosture (9.60.0). */
beginIntegrityLedger() {
this.integrityLedgerHolder.current = beginIntegrityRun({
wire: true,
composeInvariant: this.mapsPlan !== undefined,
dangling: this.integrityDanglingPresent,
claim: this.claimContract !== undefined,
// TWO HALVES (9.77.0): the operator's dial AND a declaration to arm
// on. Either alone leaves a registered `not-applicable` row.
emptyLookup: this.noticeEmptyLookups && this.integrityDanglingPresent,
// TWO HALVES (9.78.0), the same law: the operator's dial off `'off'`
// AND a tool declaring `resultColumns`. Either alone leaves two
// registered `not-applicable` rows.
columnTypes: this.checkColumnTypes !== 'off' && this.integrityColumnsPresent,
// TWO HALVES (9.83.0), and here the second is structural rather than
// a policy choice: the evidence gate owns the extractor that decides
// which tokens in an answer are values, so a dial with no gate has
// nothing whose provenance it could read. Either alone leaves a
// registered `not-applicable` row.
priorTurnEvidence: this.noticePriorTurnEvidence && this.evidenceGate !== undefined,
}, this.integrityPosture);
}
/**
* Dev posture only: throw {@link CheckerDeadError} on a run whose
* registered checkers demonstrably never ran, or whose canary went
* uncaught. Called on the SUCCESS path before the recording files, so the
* failure is the run's result, never a masked afterthought.
*/
assertIntegrityAlive() {
if (this.integrityPosture !== 'dev')
return;
this.integrityLedgerHolder.current?.assertAlive({
workExisted: this.integrityWorkExisted(),
});
}
/**
* Did this run reach work a registered check should have seen?
*
* MEASURED, not asserted — and measured from a signal the integrity code
* does not write. `llmLatestContent` is committed by the LLM stage's own
* core path on every completed call, so its presence proves a call
* happened; its absence proves the run died or paused before one, and a
* checker that filed nothing THERE is not rot, it is a run that never got
* started. Deriving this from the checks' own encounter counts would be
* circular: an unhooked check would report "no work existed" and silence
* the very alarm it should be tripping.
*/
integrityWorkExisted() {
const state = this.liveSnapshot()?.sharedState;
return state !== undefined && state.llmLatestContent !== undefined;
}
/**
* File the run's disposition rows as ONE `integrity.disposition` event and
* clear the ledger. On the finally path of both run doors — every exit,
* before the recording stops. Listener-gated like every typed event, and
* deliberately throw-proof: accounting must never change a run's outcome.
*/
fileIntegrityDisposition() {
const ledger = this.integrityLedgerHolder.current;
this.integrityLedgerHolder.current = undefined;
if (ledger === undefined)
return;
try {
const type = 'agentfootprint.integrity.disposition';
const dispatcher = this.getDispatcher();
if (!dispatcher.hasListenersFor(type))
return;
dispatcher.dispatch({
type,
payload: {
posture: this.integrityPosture,
workExisted: this.integrityWorkExisted(),
rows: ledger.report(),
},
meta: buildEventMeta({ runtimeStageId: INTEGRITY_DISPOSITION_STAGE_ID }, this.currentRunContext),
});
}
catch {
// Recorder/dispatcher failures never abort a run — the house rule.
}
}
emitRunManifest() {
const type = 'agentfootprint.agent.run_configured';
const dispatcher = this.getDispatcher();
if (!dispatcher.hasListenersFor(type))
return;
dispatcher.dispatch({
type,
payload: buildRunManifest({
agentId: this.id,
providerName: this.provider.name,
model: this.model,
hasRunConfig: this.runConfigFn !== undefined,
hasSkillBrains: this.skillBrains !== undefined,
...(this.cacheUnsupported !== undefined && { cacheUnsupported: this.cacheUnsupported }),
reactMode: this.reactMode,
memories: this.memories,
...(this.windowStrategy !== undefined && {
windowStrategyName: this.windowStrategy.name,
}),
// A graph is mounted iff `.skillGraph()` handed over its cursor
// resolver. NOT `skillGraphCascade`, which a graph mounted without the
// turn-start cascade options never sets — reading that one would
// report "no graph" for a graph that routes every turn.
...(this.skillGraphNextSkill !== undefined && {
skillGraph: {
...(this.skillGraphCascade !== undefined && {
routing: this.skillGraphCascade.strictness,
continuity: this.skillGraphCascade.continuity,
}),
...(this.skillGraphCascade?.turnRouting?.scorer !== undefined && {
scorerName: this.skillGraphCascade.turnRouting.scorer.name,
}),
},
}),
...(this.evidenceGate !== undefined && {
evidenceGatePosture: this.evidenceGate.posture,
}),
// The compositions this agent was built from, in declaration order.
// Spread value-conditionally so an agent with none passes no key at all
// — see `RunManifestSources.recipes` for why it is absent rather than
// an empty list.
...(this.appliedRecipes !== undefined && { recipes: this.appliedRecipes }),
// The store itself is never named — see RunManifestSources.artifacts.
...(this.artifactStore !== undefined && {
artifacts: {
configured: true,
placement: this.artifactPlacement !== undefined,
recordings: this.artifactRecordings !== undefined,
},
}),
}),
meta: buildEventMeta({ runtimeStageId: RUN_MANIFEST_STAGE_ID }, this.currentRunContext),
});
}
/**
* File the DECLARED skill map (9.50.0) — `agentfootprint.skill.graph_declared`,
* once per run, right after the run-configuration manifest.
*
* Same funnel, same dispatch discipline, same listener gate as the manifest
* (see `emitRunManifest` above): `run()` and `resume()` both come through
* `createExecutor`, both mint a fresh runId, and a resumed run's consumers
* deserve the topology under the runId they are joining on. The payload is
* the map `AgentBuilder.skillGraph()` projected at mount — the author's
* nodes and edges VERBATIM, never inferred from runtime hops — so a
* recording carries the complete declared topology rather than the
* fired-edges lower bound that `context.evaluated.routing[]` names.
*
* No graph, or a graph that could not state its map ⇒ no event — absent,
* never guessed.
*/
emitSkillGraphDeclared() {
if (this.skillGraphDeclared === undefined)
return;
const type = 'agentfootprint.skill.graph_declared';
const dispatcher = this.getDispatcher();
if (!dispatcher.hasListenersFor(type))
return;
dispatcher.dispatch({
type,
payload: this.skillGraphDeclared,
meta: buildEventMeta({ runtimeStageId: GRAPH_DECLARED_STAGE_ID }, this.currentRunContext),
});
}
/**
* Flush the deferred-observer backlog of the most recent run's executor,
* then await async listener completions under a deadline (RFC-001 §11 —
* the serverless / graceful-shutdown pattern). Resolves immediately with
* zeros before the first run or when `observerDelivery` is `'inline'`
* and no recorder opted into `'deferred'` itself.
*
* `pending === 0` means a full drain; non-zero honestly reports
* continuations still outstanding at the deadline — never silent loss.
*
* @example Lambda-style handler
* ```ts
* export const handler = async (event) => {
* const reply = await agent.run({ message: event.message });
* // settle "one beat behind" observer work BEFORE the freeze:
* await agent.drainObservers({ timeoutMs: 5_000 });
* return reply;
* };
* ```
*/
drainObservers(opts) {
if (!this.lastExecutor)
return Promise.resolve({ done: 0, failed: 0, pending: 0 });
return this.lastExecutor.drainObservers(opts);
}
/**
* Did the last turn stop because a LIMIT cut it short — and if so, which?
*
* `undefined` on every normal finish, including a turn that used its whole
* `maxIterations` budget and then genuinely finished. It is set only when
* the model was still asking for tools and the run refused to run them:
* `maxIterations` was reached, or a `costBudget: { onExceed: 'halt' }` was
* crossed.
*
* ## Why this is a method and not part of the answer
*
* `run()` resolves to a bare string. There is nowhere in a string to write
* "…and three tool calls never ran", which is the same wall 8.6.0 hit with
* an outstanding credential consent — and there the turn raises, because
* handing back a plausible answer for work a tool never did is a silent
* success. This is not that. A limit you configured firing is the limit
* working, and the answer is sometimes real (a model can return content AND
* tool calls). So it does not raise; it records, in committed state, where
* it is provable after the fact — `getLastSnapshot().sharedState.stoppedEarly`
* is the same value (unless the agent's `redact` names it: this accessor
* reads the live run), and this is the short way to it.
*
* When the answer came back EMPTY the library also warns once on the
* console, because an empty string reaching a user is indistinguishable
* from a bug.
*
* @example
* ```ts
* const answer = await agent.run({ message: 'audit every log file' });
* const cut = agent.stoppedEarly();
* if (cut) {
* console.log(`stopped at iteration ${cut.iteration}: ${cut.reason}`);
* console.log(`${cut.pendingToolCalls} tool call(s) never ran`);
* }
* ```
*/
stoppedEarly() {
const state = this.liveSnapshot()?.sharedState;
return state?.stoppedEarly;
}
/**
* Did the last turn's answer FAIL this agent's `outputSchema` — and how (8.18.0)?
*
* `undefined` when the answer satisfied the contract, and on any agent with
* no `.outputSchema()`. Set on every run whose final answer was judged and
* rejected, including the default `retries: 0` case where the first answer is
* the only one there was.
*
* ## Why a method, when `runTyped()` already throws
*
* Because `run()` does not, and `run()` is what a server, a queue worker and
* `standingAgent` call. Before this existed, that caller received a string
* that violated a contract they had declared, with nothing anywhere saying
* so: the retries were billed, the ledger row was written under `retries > 0`
* and absent under `retries: 0`, and the answer looked exactly like a good
* one. `runTyped()` still throws `OutputSchemaError` — that is the caller
* ASKING to be raised at, and it is unchanged.
*
* `brokenBy` is the case worth a dashboard: the model's answer PASSED and one
* of your own `act({ output })` rules rewrote it into one that fails. The run
* stops re-asking when that happens — a deterministic rule breaks the next
* answer identically, so the retries would be bought for nothing.
*
* @example
* ```ts
* const answer = await agent.run({ message: 'summarise ticket 91' });
* const unmet = agent.outputContractUnmet();
* if (unmet) {
* log.warn({ stage: unmet.stage, error: unmet.error, brokenBy: unmet.brokenBy });
* return safeDefault; // …rather than shipping `answer` as typed data
* }
* ```
*/
outputContractUnmet() {
const state = this.liveSnapshot()?.sharedState;
return state?.outputContractUnmet;
}
/**
* Did the last turn's answer state names or numbers that appear in NO tool
* result (9.35.0)?
*
* `undefined` when every value was grounded — and on any agent without
* `.namesAndNumbersFromEvidence()`. Set on a turn that shipped flagged
* (`'assist'` / `'guard'`) and on one that was refused (`'rails'`, where
* `run()` also raised `UnsupportedValuesError`, so this is what a caller
* reads in the `catch`).
*
* `revised: true` means the model was asked once to correct the values and
* they survived that turn — the fact worth alerting on, because it is a
* model that cannot ground its own claims rather than one that slipped.
*
* Remember what the verdict does NOT say: values were invented. It cannot
* tell you whether a claim built from real values is true.
*
* @example
* ```ts
* const answer = await agent.run({ message: 'which port is down?' });
* const bad = agent.unsupportedValues();
* if (bad) log.warn({ values: bad.values.map((v) => v.value), revised: bad.revised });
* ```
*/
unsupportedValues() {
const state = this.liveSnapshot()?.sharedState;
return state?.unsupportedValues;
}
/**
* The last run's findings ledger (9.101.0) — the model's OWN standings on
* its tool results, as `.findings()` recorded them: `basis` rows (what a
* call was for, declared before it ran), `standing` rows (`fact` with the
* assertions stood on, `open`, `ruled-out`, `noise` — the LAST row per
* `toolCallId` is the current reading; earlier ones are quotable history)
* and `conflict` rows (two stood-on readings that disagree, witnesses by
* identity). Undefined when nothing filed a row — never an empty array
* standing in for "no findings", and an id with no standing row is
* undeclared, never `open`.
*
* THE INPUTS LAYER files here too (honesty layer 2): an agent whose tools
* declare `askOrAssume` gets `argument` rows — the library's verdict on each
* ruled argument of each call (`source: 'default'` when a tool's rule
* assumed the value, `'model'` when the model sent one the record does not
* trace), each stamped with its conversation `turn` — WITHOUT `.findings()`.
* A reader that switches over every row kind must skip one it does not know.
*
* THE RESULTS LAYER files here too (honesty layer 3, step 7b): `period` rows
* — one per call whose result declared the period its read covered, or whose
* tool declares a period argument: `covered`, `partly-held`, `not-held`,
* `unknown`, or `undeclared` (the result said nothing about its period).
*
* Detached from the execution record (`structuredClone`), so a caller may
* keep or mutate it without touching the run's state.
*
* @example
* ```ts
* await agent.run({ message: 'which port is down?' });
* const current = new Map<string, string>();
* for (const row of agent.findings() ?? []) {
* if (row.kind === 'standing') current.set(row.toolCallId, row.standing);
* }
* ```
*/
findings() {
const ledger = this.liveSnapshot()?.sharedState
?.findingsLedger;
return ledger === undefined ? undefined : structuredClone(ledger);
}
/** The library-composed negative statements and their recorded limits.
* Detached, in-process data for the caller. Record redaction applies to the
* snapshots and emitted events, not this caller-owned value. */
answerNegatives() {
const value = this.liveSnapshot()?.sharedState
?.answerNegatives;
return value === undefined ? undefined : structuredClone(value);
}
/** The last run's answer checks, detached from its execution record.
* Undefined means no terminal validation ran, never an implicit pass. */
answerValidation() {
const report = this.liveSnapshot()?.sharedState
?.answerValidation;
return report === undefined ? undefined : structuredClone(report);
}
/**
* The limits of the last run's TYPED answer, as data — what its tools
* declared they `checked`, did not check (`notChecked`) and can never see
* (`cannotCover`), folded across the run: duplicates said once, every entry
* kept.
*
* Set when the agent has an output schema AND `.limitsTravelWithTheAnswer()`:
* a typed answer is JSON, and the limits block that option appends to a
* prose answer would stop it being JSON, so for a typed answer the limits
* travel beside it instead — here, on `turn_end.answerCoverage`, and in
* `getLastSnapshot().sharedState.answerCoverage` (served, under the agent's
* `redact`), one value three ways. The
* answer string, and so `runTyped()`, is exactly the model's.
*
* On an agent whose inputs layer is armed (a tool declares `askOrAssume`),
* `assumed` lists the values a tool's rule filled this turn — the same rows,
* read the same way, that a prose answer's "Assumed" block prints — each in
* the tool's own argument view (`'REDACTED'` with `hidden` when that view
* hides it).
*
* `undefined` when the run's tools declared no limits and nothing was
* assumed, on a prose answer (the block is in the answer string there;
* `sharedState.coverageDeclared` holds the raw rows either way), and before
* the first run. Detached from the execution record, so a caller may keep
* or mutate it.
*
* `periods` (honesty step 7b) — present only when a call's result declared
* the period its read covered: one entry per declaring call, `{ toolName,
* toolCallId, queried, held, readAt? }`, the period as the tool declared it
* — the data twin of the prose block's `Period:` lines.
*
* @example
* ```ts
* const agent = Agent.create({ provider, model })
* .tool(replicationHealth) // returns coverage(verdict, { … })
* .outputSchema(Verdict)
* .limitsTravelWithTheAnswer()
* .build();
*
* const verdict = await agent.runTyped({ message: 'is replication healthy?' });
* const limits = agent.answerCoverage();
* for (const item of limits?.cannotCover ?? []) {
* console.log(`never covered: ${item.what} (${item.why})`);
* }
* ```
*/
answerCoverage() {
const limits = this.liveSnapshot()?.sharedState
?.answerCoverage;
return limits === undefined ? undefined : structuredClone(limits);
}
/**
* How far the last run's answer stands — folded from its COMMITTED record,
* never from how sure the model sounded: `known` · `consistent` (checks ran,
* none fired — never "verified") · `not-sure` (with the reasons) · `ask` (the
* run paused on a question still waiting for a person) · `not-assessed`
* (nothing on the record could be checked).
*
* The same pure fold as `assessAnswer` on `agentfootprint/observe`, over this
* agent's last snapshot; it reads committed state, never events, so a later
* reader of the same recording folds the same standing. (This call folds the
* LIVE run. Under `redact` a recording holds the placeholder wherever the
* policy selected a value, and a fold of it stands only on what the policy
* left — the same standing unless a selected key is one the fold reads.) It resolves
* asynchronously because the fold is loaded through `import()` on first use —
* an agent that never asks does not carry it.
*
* Resolves to `undefined` when there is no answer to assess: before the first
* run, while a run is in flight, and after a run that threw or whose answer a
* rule refused (the typed error carries that verdict).
*
* `declarations` is the answer account's object, accepted as it is: the fold
* reads `tools[name].rowsAt`, where an object result keeps its rows.
*
* @example
* ```ts
* await agent.run({ message: 'Which ports on switch A are down?' });
* const a = await agent.assessment();
* if (a?.standing === 'not-sure') {
* for (const r of a.reasons) console.log(r.reason); // e.g. 'empty-undeclared'
* }
* ```
*/
async assessment(declarations) {
const snapshot = this.liveSnapshot();
if (snapshot === undefined)
return undefined;
// Settled before the fold, which cannot tell a crash from its committed state: an answer
// this run RETURNED (`lastRunAnswer`, cleared at every run's start), or a pause — the fold
// reads that one from the committed state itself. Read BEFORE the import resolves, so a
// run started meanwhile cannot change which run is assessed.
const paused = this.lastExecutor?.isPaused() === true;
if (!paused && this.lastRunAnswer === undefined)
return undefined;
// Loaded on first use, off the default graph (the findings/peel.ts precedent).
// The live state, read as values — never as a served record (`assess.ts` · `assessLive`).
const { assessLive } = await import('./agent/assessment/assess.js');
return assessLive(snapshot, declarations);
}
/**
* @internal What a mount of this agent's chart handed back
* (`core/terminalVerdict.ts` · `ReadsItsOwnOutcome`, read by every
* composition): the ANSWER, or the terminal verdict the chart ended on. A
* string, or the validated answer's `{ finalContent, answerValidationCommitted }`,
* is the answer; a verdict is read ONLY from the chart's state — handed over
* when the chart returned no result — with the one translation this agent's
* own run boundary (`finalizeResult`) uses. The `'tell-model'` consent record
* travels off tracked state, so it is raised by the run boundary only.
*/
outcomeOf(sfOutput) {
if (typeof sfOutput === 'string')
return { answer: sfOutput };
if (isValidatedDelivery(sfOutput))
return { answer: sfOutput.finalContent };
if (typeof sfOutput === 'object' && sfOutput !== null) {
const state = sfOutput;
const verdict = this.refusalVerdictOf(state) ?? this.validationVerdictOf(state);
if (verdict !== undefined)
return { verdict };
}
return { answer: sfOutput };
}
/** The records a run ends on in place of an answer, in the order the boundary raises them. */
refusalVerdictOf(state) {
// Reliability fail-fast (v2.11.5+) — the loop in callLLM writes the record
// and calls $break (`reliability/failFastRecord.ts`).
if (this.reliabilityConfig !== undefined) {
const record = failFastRecordOf(state);
if (record !== undefined)
return { kind: 'reliability-fail-fast', record };
}
// Policy halt (v2.12+) — a `PermissionChecker` returned `halt`; the
// toolCalls handler wrote a synthetic tool_result and the policyHalt*
// fields, and called $break.
const halt = state;
if (halt.policyHaltReason !== undefined && halt.policyHaltTarget !== undefined) {
const history = halt.history ?? [];
const iteration = halt.policyHaltIteration ?? 1;
// Sequence at halt time — derived from history. Includes the
// proposed call (which DID land in history as the synthetic
// tool_result for protocol compliance, but the policy denied
// execution). Filter it out so callers see only dispatched
// calls, then append the proposed entry as a hint.
const sequenceWithoutProposed = extractSequence(history.slice(0, -1), iteration);
return {
kind: 'policy-halt',
halt: {
reason: halt.policyHaltReason,
...(halt.policyHaltTellLLM !== undefined && { tellLLM: halt.policyHaltTellLLM }),
sequence: [
...sequenceWithoutProposed,
{ name: halt.policyHaltTarget, args: halt.policyHaltArgs, iteration },
],
iteration,
history,
proposed: { name: halt.policyHaltTarget, args: halt.policyHaltArgs ?? {} },
...(halt.policyHaltCheckerId !== undefined && { checkerId: halt.policyHaltCheckerId }),
},
};
}
// Message-boundary refusal (7.18+) — a `messageMiddleware` returned
// `deny`; a refusal must never be mistaken for an answer: at 'input' no
// model was asked, and at 'output' the middleware declined to release it.
if (this.messageMiddleware.length > 0 || this.findingsOptions !== undefined) {
const denied = state;
if (denied.messageDeniedReason !== undefined) {
return {
kind: 'message-denied',
denial: {
reason: denied.messageDeniedReason,
phase: denied.messageDeniedPhase ?? 'output',
middleware: denied.messageDeniedBy ?? 'middleware',
},
};
}
}
// Evidence rails (9.35.0) — the answer still states values no tool result
// carried, after the one revision the posture allows. `'assist'` and
// `'guard'` never end here: their verdict is committed state and an event.
if (this.evidenceGate?.posture === 'rails') {
const verdict = state.unsupportedValues;
if (verdict !== undefined && verdict.refused) {
return {
kind: 'unsupported-values',
refusal: {
values: verdict.values,
candidates: verdict.candidates,
revised: verdict.revised,
message: evidenceRefusalSentence(verdict.values, 'rails', verdict.revised),
},
};
}
}
return undefined;
}
/** Answer validation's verdict on a run that reached the end (7.24): blocked, or never committed. */
validationVerdictOf(state) {
const config = this.answerValidationConfig;
if (config === undefined)
return undefined;
const s = state;
if (s.answerValidationBlocked === true && s.answerValidation !== undefined) {
return { kind: 'answer-validation', report: s.answerValidation };
}
if (s.answerValidation === undefined ||
s.answerValidationCommitted !== true ||
typeof s.finalContent !== 'string') {
return {
kind: 'answer-validation',
report: {
validatorId: config.id,
validatorVersion: config.version,
mode: config.mode,
status: 'unverified',
checked: 0,
failed: 0,
unreachable: 0,
notApplicable: 0,
checks: [],
resolvedRefs: [],
schemaAccepted: false,
reason: 'delivery-incomplete',
},
};
}
return undefined;
}
finalizeResult(executor, result) {
const paused = this.detectPause(executor, result);
if (paused)
return paused;
const snap = executor.getSnapshot();
const state = snap.sharedState;
const refusal = this.refusalVerdictOf(state);
if (refusal !== undefined) {
if (refusal.kind === 'message-denied' && refusal.denial.phase === 'output') {
// The chart stopped cleanly, so ErrorBridge's onRunFailed cannot
// close public streams. This API boundary owns the refusal on both
// run and resume. Name it as a virtual stage, not a fabricated
// engine failure, and never echo policy text into a public stream.
this.dispatcher.dispatch({
type: 'agentfootprint.error.fatal',
payload: {
error: 'The output policy withheld this answer.',
stage: '__output_admission__',
scope: 'run',
},
meta: buildEventMeta({ runtimeStageId: '__output_admission__' }, this.currentRunContext),
});
}
// The verdict was decoded from the LIVE state above; the snapshot a
// fail-fast error carries is the run's audit trail — "consumers persist
// this" (`ReliabilityFailFastError.snapshot`) — so it is the SERVED one.
throw terminalErrorOf(refusal, this.getLastSnapshot());
}
// Credential-consent translation (8.6.0, `'tell-model'` only) — a tool
// DECLARED a credential, the provider answered `authorization-required`,
// and the model was told to route around it. The run reached the end
// anyway, which means it is about to hand back an answer string for work a
// tool never did.
//
// `AgentOutput` is a bare string, so there is nowhere to annotate "…and a
// consent is still outstanding". An event alone would relocate the silent
// success rather than remove it. So the turn raises, carrying the URL to
// the caller — the party that can actually act on it. Under the default
// `'pause'` this is unreachable for the first block (the run pauses and
// `detectPause` returned above); it still fires if a RESUME found consent
// ungranted and the model then finished the turn on its own.
if (this.consentOutstanding.size > 0) {
const [record] = [...this.consentOutstanding.values()];
if (record) {
this.consentOutstanding.clear();
throw new CredentialConsentRequiredError(record);
}
}
if (result instanceof Error)
throw result;
const invalid = this.validationVerdictOf(state);
if (invalid !== undefined)
throw terminalErrorOf(invalid);
if (this.answerValidationConfig !== undefined)
return state.finalContent;
if (typeof result === 'string')
return result;
throw new Error('Agent: unexpected result shape — expected final-answer string');
}
// ─── Chart assembly ────────────────────────────────────────────
buildChart() {
const governsOutput = hasOutputMiddleware(this.messageMiddleware);
const provider = this.provider;
const model = this.model;
const temperature = this.temperature;
const maxTokens = this.maxTokens;
const systemPromptValue = this.systemPromptValue;
const registry = this.registry;
// (registryByName + toolSchemas redefined below using
// `augmentedRegistry` which adds the auto-attached `read_skill`
// tool when Skills are registered.)
const _legacyRegistry = registry;
void _legacyRegistry;
const maxIterations = this.maxIterations;
const pricingTable = this.pricingTable;
const costBudget = this.costBudget;
const permissionChecker = this.permissionChecker;
const credentialProvider = this.credentialProvider;
const artifactStore = this.artifactStore;
// Cache layer (v2.6) — capture for the seed + chart-build closures.
// `systemPromptCachePolicy` is fed into the CacheDecision subflow's
// inputMapper. `cacheStrategy` is consulted by BuildLLMRequest at
// run-time (Phase 7+ for the actual prepareRequest call). For
// Phase 6b the chart mounts the stages but BuildLLMRequest is a
// pass-through; Phase 7 lights up the strategy call.
const systemPromptCachePolicy = this.systemPromptCachePolicy;
const cachingDisabled = this.cachingDisabledValue;
const cacheStrategy = this.cacheStrategy;
// ── Steps-as-data (9.18.0): fold the declared procedures ONCE ──────
// One frozen plan per stepped skill, threaded as closures into the four
// seams that consult it (Evaluate re-key, tools-slot narrowing,
// tool-calls advance/skip, the Route step judge + nudge). Empty →
// `stepPlanFor` stays undefined and not one of those seams runs a new
// line (zero-cost-when-unused).
const stepPlans = foldStepPlans(this.injections);
const stepPlanFor = stepPlans.size > 0 ? (skillId) => stepPlans.get(skillId) : undefined;
// seed extracted to ./agent/stages/seed.ts (v2.11.2). Factory takes
// chart-build-time constants + per-run mutable accessors so the
// resume side-channel and current run id remain dynamic.
// toolSchemas is finalized further down; pass a getter that reads
// the eventual const at stage-execution time.
let toolSchemasResolved = [];
// The `Tool.wants` declarations seed puts on the record (9.88.0), late-bound
// for the same reason `toolSchemasResolved` is: the registry is harvested
// further down, and seed is built here. Stays `undefined` unless the
// evidence gate arms the staged-refs nudge — the exact condition under
// which request assembly can compose that line at all.
let seededToolWants;
// The stores the conversation itself is kept in — the durable anchor seed
// resolves the turn number against. Deduplicated (several memories over
// one store is the common shape) and reference-stable, so the scan runs
// once per store per run.
const conversationStores = Array.from(new Set(this.memories
.filter((m) => m.write !== undefined && m.corpus === undefined && m.store !== undefined)
.map((m) => m.store)));
// The inputs layer (honesty layer 2) — late-bound like `toolSchemas`: the
// registry that says whether a tool is ruled is harvested further down.
let seededRuledTools;
let seededHonestyLayers;
// The findings ledger's restore (9.101.0), WIDENED (honesty layer 2): wired
// whenever the ledger OR an honesty layer is armed — an agent without
// `.findings()` files argument rows too, and a continued conversation must
// keep them. Negative-claim observations also persist independently of those
// layers. Still value-conditional inside seed (only when rows exist).
let ledgerRestoreArmed = this.findingsOptions !== undefined || this.negativeClaimsConfig !== undefined;
const consumeResumeLedger = () => {
const l = this.pendingResumeFindingsLedger;
this.pendingResumeFindingsLedger = undefined;
return l;
};
const seed = buildSeedStage({
maxIterations,
// The forced-output tool's name (9.88.0) — a build-time constant, put on
// the record once so a rebuild can name it without reading a receipt.
...(this.outputEnforcement?.schemaTool !== undefined && {
forcedOutputToolName: this.outputEnforcement.schemaTool.name,
}),
cachingDisabled,
...(costBudget !== undefined && { costBudgetOnExceed: costBudget.onExceed }),
get toolSchemas() {
return toolSchemasResolved;
},
get toolWantsByName() {
return seededToolWants;
},
get ruledTools() {
return seededRuledTools;
},
get honestyLayers() {
return seededHonestyLayers;
},
// The time layer's clock: the draft `run()` read (refusing a run with no
// zone), stamped by seed as the turn's one `clock` row. Absent → the
// deps object seed always had.
// The caller's clock belongs to the agent's OWN run: a member (another
// runner's executor) gets the builder's fallback, never a concurrent
// direct caller's `time` (`memberRun.ts`).
...(this.timeOptions !== undefined && {
timeClock: (scope) => this.ownScopes.has(scope)
? this.seedClockDraft()
: draftClock(undefined, this.timeOptions ?? {}),
}),
// The time layer's reader: seed reads the person's words once per turn,
// after the clock. Absent → nothing is read.
...(this.timeOptions?.reader !== undefined &&
this.timeOptions.policy !== undefined && {
timeReader: { reader: this.timeOptions.reader, policy: this.timeOptions.policy },
}),
// Declared sources (honesty layer 2): the seed twin plants `_findings.from`
// on ruled tools, and the run's `messageFrom` constant is written — only
// under the arm (refused at build without the inputs layer).
...(this.argumentSourcesArmed === true && { argumentSources: true }),
// The answer check's tool-call ledger — value-conditional, so an agent
// without answer validation or negative claims keeps its prior shape.
...((this.answerValidationConfig !== undefined ||
this.negativeClaimsConfig !== undefined) && { toolCallLedger: true }),
...(this.negativeClaimsConfig !== undefined && { negativeClaimsArmed: true }),
get consumePendingResumeFindingsLedger() {
return ledgerRestoreArmed ? consumeResumeLedger : undefined;
},
consumePendingResumeHistory: () => {
const h = this.pendingResumeHistory;
this.pendingResumeHistory = undefined;
return h;
},
...(this.evidenceGate !== undefined &&
this.evidenceGate.posture !== 'assist' && {
consumePendingEvidenceRecovery: () => {
const recovery = this.pendingEvidenceRecovery;
this.pendingEvidenceRecovery = undefined;
return recovery;
},
}),
consumePendingResumeFolded: () => {
const f = this.pendingResumeFolded;
this.pendingResumeFolded = undefined;
return f;
},
// The findings ledger (9.101.0): the arm, the serve mode it puts on the
// record (the `forcedOutputToolName` precedent — a build-time constant
// the rebuild reads instead of the receipt), and the continued
// conversation's rows, all under the one gate — an unarmed agent hands
// seed exactly the deps object it always did.
...(this.findingsOptions !== undefined && {
findings: true,
findingsServe: this.findingsOptions.serve ?? 'ledger-and-facts',
findingsContextBytes: this.findingsOptions.maxContextBytes,
// The answer-turn ask (9.103.0): VALUE-conditional inside the arm —
// the default `'none'` threads nothing and writes no key.
...(this.findingsOptions.answerAsk === 'quote-facts' && {
findingsAnswerAsk: 'quote-facts',
}),
}),
// The declared ontology (9.106.0): the whole frozen map, which seed
// writes ONCE as the run constant `ontology` — the `findingsServe`
// precedent, a build-time fact the rebuild reads from the RECORD. An
// unarmed agent hands seed exactly the deps object it always did.
...(this.ontology !== undefined && {
ontology: this.ontology,
ontologyTools: () => this.ontologyTools,
}),
// The conversation's inherited skill cursor (SG-C). Consumed (cleared)
// on every run; HONORED only when the mounted graph declared
// `continuity: 'conversation'` — the same one-option-one-behavior gate
// the write side (`checkpoint()`) applies.
consumePendingResumeSkillCursor: () => {
const c = this.pendingResumeSkillCursor;
this.pendingResumeSkillCursor = undefined;
return c;
},
restoreSkillCursor: this.skillGraphCascade?.continuity === 'conversation',
// Steps (9.18.0): gate the per-run stepPointer/stepNudgeSpent reset on
// the feature, so every other agent seeds exactly the keys it always did.
...(stepPlanFor !== undefined && { hasSteps: true }),
// Escalation (9.19.0): same gate discipline — the per-run counter/flip
// reset exists only when the policy does (de-escalation IS the seed).
...(this.skillBrains?.escalation !== undefined && { hasEscalation: true }),
// The evidence gate's one bounded revision (9.35.0) — a per-turn budget,
// reset here. Only a posture that can revise writes the key.
...((this.typedNegativeClaimsConfig !== undefined ||
(this.evidenceGate !== undefined && this.evidenceGate.posture !== 'assist')) && {
hasEvidenceRevision: true,
}),
// Under an enforcing answer validation the tools stay off for a run
// resumed after its revision was asked (`toolsOffAfterRevision`).
...(this.revisionWithholdsTools() && { toolsOffAfterRevision: true }),
getCurrentRunId: () => this.currentRunContext?.runId,
// WHO this run is for, when the caller named nobody (9.10.0). Seed uses
// it for exactly one rung of the identity ladder — see `seedFrom` — and
// an explicit identity outranks it there. Read through an accessor for
// the same reason the runId is: the chart is built once, and this
// changes every run.
getCurrentSessionId: () => this.currentRunContext?.sessionId,
// Whether this stage runs in THIS agent's own executor, or inside another
// runner's (a composition, a chart a caller built) — where the accessors
// above describe some other run (`memberRun.ts`). Asked per scope.
isOwnRun: (scope) => this.ownScopes.has(scope),
// WHICH TURN THIS IS (9.6.0). Only memories that WRITE the conversation
// are consulted: they are the ones whose entry ids are turn-stamped, and
// a corpus (`.rag(...)`, which reads under its own namespace) has no
// turns at all. Empty list → seed stays synchronous and makes no store
// call, so an agent without memory is unchanged.
...(conversationStores.length > 0 && { conversationStores }),
// The `'input'` half of the message chain, run BEFORE `userMessage` and
// `history` are committed — see SeedStageDeps.messageMiddleware.
...(this.messageMiddleware.length > 0 && { messageMiddleware: this.messageMiddleware }),
// `.configure()` — seed is where run-level facts are decided and
// committed, so the resolver rides that commit. The closure supplies
// the build-time defaults so a resolver can decide RELATIVE to them
// ("upgrade the model when the message is long") instead of having to
// restate them.
...(this.runConfigFn && {
resolveRunConfig: (input) => {
const ctx = {
message: input.message,
...(input.identity !== undefined && { identity: input.identity }),
runId: this.currentRunContext?.runId ?? 'unknown',
defaults: { model, instructions: systemPromptValue },
};
return this.runConfigFn?.(ctx);
},
}),
});
// Tool registry composition extracted to ./agent/buildToolRegistry.ts.
// Composes static .tool() registry + auto-attached read_skill (+ the
// auto-attached `present` tool when a store is attached, 9.22.0) +
// skill-supplied tools (with autoActivate scoping); validates
// name uniqueness; produces the dispatch map.
const { registryByName, toolSchemas, toolDeclaringSkills, toolClaimants } = buildToolRegistry(registry, this.injections, {
hasArtifactStore: artifactStore !== undefined,
// The reserved-argument refusal (9.101.0) — only when the ledger is
// armed may a registry tool's own `_findings` be refused.
...(this.findingsOptions !== undefined && { findings: true }),
// …and, under declared sources (either door), a RULED tool's: the arm
// plants `_findings.from` on it and its sentences name that argument.
...(this.argumentSourcesArmed === true && { argumentSources: true }),
});
// The declared ontology's tool edges (9.106.0): every name a node's `via`
// names must be a tool this registry can dispatch — the static registry,
// the framework's doors and every skill's tools, `registryByName` being
// the ONE map that holds all of them. Refused at BUILD, naming the
// ontology and the tool: a map that points a need at a tool nobody can
// call is a map that lies, and only the operator can fix it. Provider-
// served tools are only met at dispatch and cannot be named here.
if (this.ontology !== undefined) {
// The join (9.108.0): the same walk, collecting which skills declare
// each `via` name — `toolDeclaringSkills` is the registry's own fact,
// in declaration order; a name no skill declares is not in it.
const tools = {};
for (const [nodeId, node] of Object.entries(this.ontology.nodes)) {
for (const held of node.sources ?? []) {
for (const toolName of held.via ?? []) {
if (!registryByName.has(toolName)) {
throw new Error(`Agent: ontology '${this.ontology.id}' names tool '${toolName}' that is not ` +
`registered (node '${nodeId}', source '${held.source}'). Register the tool ` +
`with .tool() or on a skill, or drop it from the node's \`via\`.`);
}
const owners = toolDeclaringSkills.get(toolName);
if (owners !== undefined && owners.length > 0)
tools[toolName] = [...owners];
}
}
}
this.ontologyTools = Object.keys(tools).length > 0 ? Object.freeze(tools) : undefined;
}
// A statically registered tool that declares `wants` on an agent with no
// store is configuration that lies: every call would be refused at
// dispatch for a gap only the operator can close. Refused at BUILD,
// naming the tool (provider-served tools are only met at dispatch — the
// dispatch-time refusal covers those).
if (artifactStore === undefined) {
for (const [toolName, registered] of registryByName) {
if (registered.wants !== undefined) {
throw new Error(`Agent: tool '${toolName}' declares artifact arguments (wants: ` +
`{ ${Object.entries(registered.wants)
.map(([arg, kind]) => `${arg}: '${kind}'`)
.join(', ')} }) but no artifact store is attached, so its refs could never ` +
`resolve and every call would be refused. Pass \`artifacts\` to Agent.create ` +
`(inMemoryArtifacts(), fileArtifacts({ directory }), sqliteArtifacts({ file }), ` +
`or any ArtifactStore), or drop the tool's \`wants\`.`);
}
// The same law for the typed-HITL half (9.24.0): a statically
// registered `checkInComponent.propsRef` on a storeless agent is a
// ref the screen could never redeem — every tripped gate would be
// refused at raise for a gap only the operator can close.
if (registered.checkInComponent?.propsRef !== undefined) {
throw new Error(`Agent: tool '${toolName}' declares checkInComponent.propsRef ` +
`('${registered.checkInComponent.propsRef}') but no artifact store is attached, ` +
`so the answering screen could never redeem it and every tripped check-in would ` +
`be refused at raise. Pass \`artifacts\` to Agent.create (inMemoryArtifacts(), ` +
`fileArtifacts({ directory }), sqliteArtifacts({ file }), or any ArtifactStore), ` +
`or carry the payload inline as checkInComponent.props.`);
}
}
}
// The composedOf drift gate (9.76.0), judged HERE and not at defineTool —
// the one moment the catalog is complete. A tool that declares its
// ingredients (`composedOf`, the runbookAsTool law) whose ingredient was
// renamed or never registered fails the BUILD by name, instead of failing
// its first run inside a stage. Tools delivered by a ToolProvider are
// invisible to this check (no build-time list — the 9.72.0 caveat), so
// with a provider configured an unmatched name is a dev-mode heads-up
// rather than a refusal: the ingredient may genuinely arrive at dispatch.
for (const [composedName, registered] of registryByName) {
for (const ingredient of registered.composedOf ?? []) {
if (registryByName.has(ingredient))
continue;
if (this.externalToolProvider !== undefined) {
if (isDevMode()) {
// eslint-disable-next-line no-console
console.warn(`[agentfootprint] tool '${composedName}' declares composedOf ingredient ` +
`'${ingredient}', which is not in the static catalog. A ToolProvider is ` +
`configured, so it may arrive at dispatch — but provider-delivered tools ` +
`cannot be drift-checked at build. If '${ingredient}' is static, this is ` +
`the rename the check exists to catch.`);
}
continue;
}
throw new Error(`Agent: tool '${composedName}' declares composedOf ingredient '${ingredient}', ` +
`but no tool of that name is registered on this agent. The declaration names ` +
`the tools its procedure calls through ctx.tools, and an ingredient that is ` +
`not in the dispatch map fails at its first inner call — refused at build ` +
`instead. Register '${ingredient}' (or fix the name in composedOf).`);
}
}
// Late-bind toolSchemas into the seed stage's deps (the factory was
// built earlier with a getter; this resolves the actual value).
toolSchemasResolved = toolSchemas;
// ── The inputs layer (honesty layer 2) — armed ONCE, here ──────────
// A registered tool (static, skill-carried, or an MCP tool registered on
// the builder) that declares argument rules arms the mount — the
// `toolGrounding` harvest precedent, over the same catalog. Tools a
// ToolProvider serves are invisible here, so `.inputsLayer()` arms the
// mount for them; without it their calls are refused at dispatch (fail
// closed). Unarmed: nothing below is mounted, decorated, read or written.
const ruledTools = new Map([...registryByName.entries()].filter(([, tool]) => tool.askOrAssume !== undefined || tool.period !== undefined));
const inputsArmed = ruledTools.size > 0 || this.inputsLayerOption === true;
this.inputsLayerArmed = inputsArmed;
// DECLARED SOURCES (honesty layer 2, `.findings({ argumentSources: true })`
// or `.inputsLayer({ argumentSources: true })`) — the model's
// `_findings.from`, checked by the inputs layer. It has nothing to act on
// without the layer, and configured-and-inert looks exactly like
// configured-and-working: refused here, at build. (The `.inputsLayer()`
// door arms the layer itself, so only the findings door can reach this.)
const argumentSources = this.argumentSourcesArmed === true;
if (argumentSources && !inputsArmed) {
throw new Error('Agent: .findings({ argumentSources: true }) needs the inputs layer — the declared ' +
'sources are read and checked on the calls of a tool that declares argument rules ' +
'(askOrAssume), and this agent registers none. Register such a tool, or arm the ' +
'layer for ToolProvider tools with .inputsLayer() (which can arm the sources itself: ' +
'.inputsLayer({ argumentSources: true })).');
}
// ── The results layer (honesty layer 3, step 7b) — armed ONCE, here ──
// A registered tool that declares a `ToolPeriod` (the inputs layer's
// declaration: which argument sets the period) arms the mount, over the
// same catalog: its results owe a period, and a result that declares none
// is recorded as silence. `.resultsLayer()` arms it for tools the build
// cannot see and for tools that declare a period only on their results.
// Unarmed: nothing below is mounted, read or written.
const resultsArmed = [...registryByName.values()].some((tool) => tool.period !== undefined) ||
this.resultsLayerOption === true;
this.resultsLayerArmed = resultsArmed;
if (inputsArmed && ruledTools.size > 0)
seededRuledTools = ruledTools;
// ── The answer layer (honesty layer 4) — armed by its option alone ─────
const answerArmed = this.answerLayerOption !== undefined;
// The run constant names every armed layer; the restore is wired whenever
// one is (adopted Q6) — still value-conditional inside seed.
if (inputsArmed || resultsArmed || answerArmed) {
seededHonestyLayers = honestyLayersOf({
inputs: inputsArmed,
argumentSources,
results: resultsArmed,
answer: answerArmed,
});
ledgerRestoreArmed = true;
}
// The time layer's rows ride the same ledger: a continued conversation
// keeps its earlier turns' clocks (each turn files its own).
if (this.timeOptions !== undefined)
ledgerRestoreArmed = true;
// The script record's rows too: a continued conversation keeps each
// earlier call's facts — seed drops their text (`scripts/rows.ts`).
const scriptRecordArmed = this.evidenceGate?.code === 'match' || this.scriptTravel !== undefined;
if (scriptRecordArmed)
ledgerRestoreArmed = true;
// The gate's admissible set, for the record (9.50.0): declared hops from
// the cursor plus the open skills — the SAME two resolvers the read_skill
// offer and the refusal messages are built from (`readSkillOfferFor`),
// composed once here so `context.evaluated.cursorMove.reachable` can
// never drift from the verdicts. Open ids are fixed after build, so they
// are computed once, exactly as the offer builder does.
const openForReachable = this.skillGraphReachable ? this.openSkillIds() : [];
// Per-role skill visibility (9.11.0). The RESOLVER is handed to the
// Injection Engine, the fact's one owner: its Gather stage resolves the
// ids once per iteration, BEFORE anything decides, so the cursor and the
// evaluator refuse a hidden skill on every activation path; the mount
// publishes them on scope for every model-facing composer after it.
const hiddenSkillIdsForEngine = this.hiddenSkillIdsNow();
const injectionEngineSubflow = buildInjectionEngineSubflow({
injections: this.injections,
...(hiddenSkillIdsForEngine && { hiddenSkillIds: hiddenSkillIdsForEngine }),
...(this.skillGraphNextSkill && { nextSkill: this.skillGraphNextSkill }),
...(this.skillGraphExplainNextSkill && {
explainNextSkill: this.skillGraphExplainNextSkill,
}),
...(this.skillGraphReachable && {
reachableSkills: (currentSkillId) => [
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
...new Set([...this.skillGraphReachable(currentSkillId), ...openForReachable]),
],
}),
...(this.skillGraphSupersededEntries && {
supersededEntries: this.skillGraphSupersededEntries,
}),
// Steps (9.18.0): the Evaluate stage owns the pointer's tenure re-key.
...(stepPlanFor !== undefined && { stepPlanFor }),
// The maps kernel (9.58.0): the Evaluate stage owns the engagement
// advance, with the same ctx the triggers gate on.
...(this.mapsPlan !== undefined && { engagement: this.mapsPlan }),
});
// The turn-start slot's occupant (SG-C). RouteTurn — the cascade — mounts
// ONLY when the graph runs it: a classifier is configured, or the mount
// declared `continuity: 'conversation'`. Every other graph keeps exactly
// what it had: scorer graphs keep PickEntry byte-for-byte (no cascade, no
// turn_routed — the zero-delta law), plain graphs keep no stage at all.
const cascade = this.skillGraphCascade;
const runsCascade = cascade !== undefined &&
cascade.turnRouting !== undefined &&
(cascade.turnRouting.scorer !== undefined || cascade.continuity === 'conversation');
const hiddenSkillIdsForRouting = this.hiddenSkillIdsNow();
const routeTurnStage = runsCascade
? makeRouteTurnStage({
turnRouting: cascade.turnRouting,
...(this.skillGraphScoreEntries && { scoreEntries: this.skillGraphScoreEntries }),
continuity: cascade.continuity,
strictness: cascade.strictness,
isNode: (id) => cascade.nodeIds.has(id),
...(hiddenSkillIdsForRouting && { hiddenSkillIds: hiddenSkillIdsForRouting }),
// The tier-3 decider (9.19.0) — the out-of-band menu resolver.
// `AgentBuilder.build()` already refused a decider on a mount
// that never runs the cascade, so it always reaches its stage.
...(this.skillBrains?.decider !== undefined && {
decider: {
provider: this.skillBrains.decider.provider,
...(this.skillBrains.decider.model !== undefined && {
model: this.skillBrains.decider.model,
}),
defaultModel: model,
runConfigured: this.runConfigFn !== undefined,
},
}),
})
: undefined;
// Relevance entry router — a once-per-turn stage (off the ReAct loop) that
// picks the starting skill by embedding similarity. Only built (and mounted)
// when the graph was created with `.entryByRelevance()` AND the cascade did
// not subsume it.
const pickEntryStage = !routeTurnStage && this.skillGraphScoreEntries
? makePickEntryStage(this.skillGraphScoreEntries)
: undefined;
// With `.configure()` the base prompt becomes a function of the run: the
// slot reads the instructions seed committed, falling back to `.system()`
// when the resolver left it alone. `reason` follows the same fork so the
// context record names whichever one actually supplied the text.
// Per-slot budgets (8.11.0). Each key is forwarded only when the consumer
// set it, so an unset slot keeps its own default rather than being pinned
// to `undefined` at the call site.
const budget = this.contextBudget;
const systemPromptSubflow = buildSystemPromptSlot({
...(this.runConfigFn
? {
prompt: (args) => args.instructions ?? systemPromptValue,
reason: (args) => args.instructions !== undefined ? 'Agent.configure()' : 'Agent.system()',
}
: {
prompt: systemPromptValue,
reason: 'Agent.system()',
}),
...(budget?.systemPrompt !== undefined && { budgetCap: budget.systemPrompt }),
});
const messagesSubflow = buildMessagesSlot({
...(budget?.messages !== undefined && { budgetCap: budget.messages }),
});
// Cache shared between buildToolsSlot (writer, each iteration) and
// buildToolCallsHandler (reader, same iteration). Holds the resolved
// Tool[] from `provider.list(ctx)` so dispatch doesn't re-invoke `list()`
// — vital for async network providers.
//
// LIFETIME, stated correctly (9.92.0): the chart is built ONCE, at
// construction (`initChart`), and reused by every `run()` and `resume()`
// of this Agent — so this cache and the record below outlive a run. They
// are safe because one Agent runs one turn at a time and the slot
// overwrites them every iteration before dispatch reads them. What that
// does NOT cover is a resume in a FRESH instance, whose closure is empty
// and whose Compose never re-runs before the resumed dispatch — which is
// why the pause paths carry `pausedToolParty` on the checkpoint.
const providerToolCache = { current: [] };
// Who put each name on the wire (9.92.0) — the same closure-shared shape,
// written by the tools slot's merge and read by dispatch so the party
// whose contract the model read is the party that answers. `lastServed`
// is the run's memory of the same fact, for a name that left the wire.
const servedTools = { current: new Map(), lastServed: new Map() };
// THE ONE DISPATCH RESOLVER, for the inputs layer (honesty layer 2): built
// from the SAME deps objects the dispatch handler builds its own from, so
// the layer reads a call's rules off the implementation that will answer.
const resolveForLayer = buildToolResolver({
registryByName,
...(this.externalToolProvider && { externalToolProvider: this.externalToolProvider }),
...(this.externalToolProvider && { providerToolCache }),
servedTools,
toolClaimants,
notServed: notServedResult,
});
// A tool's `displayName` — the app's words for a call, read by a person — read
// through the same resolver, so a ToolProvider's tool is read as well, and through
// the one rule (`lib/displayName.ts` · `readDisplayName`), so a value nobody vetted
// is never printed. Wired only where a tool can declare one: on every other agent
// the final branch is the one it always was.
const displayNameOf = this.externalToolProvider !== undefined ||
[...registryByName.values()].some((tool) => tool.displayName !== undefined)
? (toolName) => readDisplayName(resolveForLayer(toolName).tool?.displayName)
: undefined;
const readSkillFor = this.readSkillOfferFor();
// Per-role skill visibility. The Injection Engine resolves the ids once per
// iteration and publishes them on scope (`buildInjectionEngineSubflow.ts` ·
// `hiddenSkillIdsOf`); the tools slot reads that published set through its
// mount. Since 9.86.0 the description is NOT the only reader — the read_skill gate's refusals, the
// `skill.rejected` payload, the propose-transition refusal and the dispatch
// roster all filter through that published set
// (`stages/toolCalls.ts` · "── Skill-graph read_skill GATE" the gate, `toolCalls.ts` ·
// "── THE REFUSAL SPEAKS WITH THE FILTERED SETS" the propose-transition refusal,
// `toolCalls.ts` · `dispatchRoster`). Nothing is cached here: an
// earlier draft of 9.84.0 cached the resolved ids so the gate could filter
// the self-call notice's move offer through them; the notice no longer names
// a destination at all (see `selfCallNotice`), so there is nothing left to
// filter and the cache went with the clause that needed it.
// Registration-time owner stamps (9.60.0) — the identity edges the
// integrity checks read. Built once per chart from the registry.
const toolOwners = new Map(registry
.filter((r) => r.tool.owner !== undefined)
.map((r) => [r.name, r.tool.owner]));
// The declared argument-ground edges (9.60.0; catalog-wide since 9.72.0)
// — read by callLLM's dangling-reference and unsupported-argument checks.
//
// Harvested from `registryByName` — the FULL declared catalog (static
// `.tool()` registrations PLUS every skill-carried tool, autoActivate/
// scoped ones included) — and not from the static registry alone, which
// was the field bug a consumer's MCP parity work surfaced: an app whose
// `argumentsFrom` tools all ride skills (delivered when a skill
// activates) never armed the choice-seam pair, and its disposition rows
// read {checked: 0, notApplicable: 1} forever while the app's own
// comments believed the checks ran.
//
// ARMING IS COMPUTABLE UP FRONT, and that is a build-time fact, not a
// hope: a skill tool's DECLARATION is known at chart build (defineSkill
// carries the Tool object into `buildToolRegistry`'s dispatch map) even
// though the tool reaches the model only after its skill activates — so
// this harvest, and the `dangling` flag `beginIntegrityRun` reads at run
// start, may see the whole catalog before any skill has fired. The
// runtime checks stay correctly scoped on their own: dangling-reference
// intersects this map with the tools THIS call actually served, and
// unsupported-argument with the calls the model actually made.
//
// CAVEAT, stated rather than papered over: ToolProvider-DELIVERED tools
// remain invisible here. `ToolProvider.list(ctx)` is opaque and
// per-iteration — there is no build-time list to harvest — so a provider
// tool declaring `argumentsFrom` arms nothing. (MCP tools are NOT in that
// hole: `mcpClient(...).tools()` registers them statically, and 9.71.0
// carries their declarations across the wire.)
const toolGrounding = new Map([...registryByName.entries()]
.filter(([, tool]) => tool.argumentsFrom !== undefined)
.map(([name, tool]) => [name, tool.argumentsFrom]));
this.integrityDanglingPresent = toolGrounding.size > 0;
// The column-type contract's declared half (9.78.0) — harvested from the
// SAME catalog and with the same ToolProvider caveat, so an MCP-carried
// `resultColumns` (which rides `_meta`) arms the check exactly as a
// locally-defined one does.
// `flatMap` rather than filter-then-assert: the narrowing is real here,
// so the twin harvest above's non-null assertion is not inherited.
const toolColumns = new Map([...registryByName.entries()].flatMap(([name, tool]) => tool.resultColumns === undefined ? [] : [[name, tool.resultColumns]]));
this.integrityColumnsPresent = toolColumns.size > 0;
// The staged-refs join's other half (grounded numbers): `Tool.wants` by
// tool name, harvested the same way and with the same ToolProvider caveat.
// Consumed only by the evidence gate (the callLLM nudge and the recheck
// correction), so an agent without the gate never reads it.
const toolWants = toolWantsOf(registryByName);
// …and onto the record (9.88.0), under the SAME arming condition callLLM
// uses below, so the rebuild composes the nudge exactly when the run could.
// A plain record: a `Map` written to scope round-trips to `{}`.
if (this.evidenceGate?.nudge === true && toolWants.size > 0) {
seededToolWants = Object.fromEntries([...toolWants].map(([name, kinds]) => [name, kinds]));
}
const toolsSubflow = buildToolsSlot({
tools: toolSchemas,
...(toolOwners.size > 0 && { toolOwners }),
// The kernel's map cards, for the compose-seam integrity backstop.
...(this.mapsPlan !== undefined && {
mountedMaps: this.mapsPlan.maps.map((m) => ({ id: m.id, toolNames: m.toolNames })),
}),
integrityLedger: this.integrityLedgerHolder,
...(this.externalToolProvider && { toolProvider: this.externalToolProvider }),
...(this.externalToolProvider && { providerToolCache }),
// The caller of THIS run, for `ToolDispatchContext.callerIdentity`: the
// instance's caller on its own run, the member's run on a mount — never a
// derived identity (the `hiddenSkillIdsNow` precedent).
...(this.externalToolProvider && {
callerIdentity: (scope) => this.ownScopes.has(scope) ? this.lastRunIdentity : memberIdentityIn(scope),
}),
toolClaimants,
servedTools,
...(readSkillFor && { readSkillFor }),
...(budget?.tools !== undefined && { budgetCap: budget.tools }),
// Steps (9.18.0): per-step narrowing + banner + the skip_step offer.
...(stepPlanFor !== undefined && { stepPlanFor }),
// The findings ledger (9.101.0): the ONE decoration site is inside this
// slot; the gate rides in value-conditionally.
...(this.findingsOptions !== undefined && { findings: true }),
// The inputs layer (honesty layer 2): the same site decorates a ruled
// tool's schema first — value-conditional, the same grammar.
...(inputsArmed && { inputsLayer: true }),
// …and, under `.time()`, the ONE served time line: the windows the reader settled this turn
// (`.time({ reader })`, step T6b) in each served period tool's form — and the window the
// person set in the app's time control (`time.window`), a fact about the person's turn,
// served on every request whether or not a served tool declares a period (G14), so it
// needs no inputs layer. `reader` (write the line every composition) only beside the
// inputs layer, the one place a reading is asked and served.
...(this.timeOptions !== undefined && {
timeWindows: {
...(this.timeOptions.zone !== undefined && { appZone: this.timeOptions.zone }),
...(inputsArmed && this.timeOptions.reader !== undefined && { reader: true }),
},
}),
// …and, under `.time()` (step T8), the turn's time limits in the same line.
...(inputsArmed && this.timeOptions !== undefined && { timeLimits: true }),
// …and, under declared sources, plants `_findings.from` on ruled tools only.
...(argumentSources && { argumentSources: true }),
// Tool choice by classifier (9.105.0): the pick and the narrowing live
// inside this slot too, at the same site; value-conditional.
...(this.toolChoiceOptions !== undefined && {
toolChoice: {
classifier: this.toolChoiceOptions.classifier,
...(typeof this.toolChoiceOptions.serve === 'object' && {
top: this.toolChoiceOptions.serve.top,
}),
...(this.toolChoiceOptions.alwaysServe !== undefined && {
alwaysServe: this.toolChoiceOptions.alwaysServe,
}),
},
}),
});
// callLLM extracted to ./agent/stages/callLLM.ts (v2.11.2). Same
// late-binding pattern as seed for toolSchemas (computed below).
const callLLM = buildCallLLMStage({
onReplayPrepared: (preparation) => {
if (this.checkpointTracker !== undefined &&
this.inFlightRunId === this.checkpointTracker.runId)
retainReplayRetirements(this.checkpointTracker, preparation);
},
...((this.typedNegativeClaimsConfig !== undefined ||
(this.evidenceGate !== undefined && this.evidenceGate.posture !== 'assist')) && {
hasEvidenceRecovery: true,
}),
// The revision calls no tools under an enforcing answer validation: only
// then does request assembly read `toolsOffAfterRevision`.
...(this.revisionWithholdsTools() && { toolsOffAfterRevision: true }),
// The findings ledger (9.101.0): the choice seam and `postValidate`
// read peeled args / content under `findings`; the SERVING (step 3) —
// the ledger piece after the recovery piece, the wire-only collapse of
// judged results — rides `hasFindingsLedger` with its mode, the same
// value seed records as the run constant `findingsServe`. One gate,
// the `hasEvidenceRecovery` grammar: an unarmed agent hands the stage
// exactly the deps it always did and reads no new key.
...(this.findingsOptions !== undefined && {
findings: true,
hasFindingsLedger: true,
findingsServe: this.findingsOptions.serve ?? 'ledger-and-facts',
findingsContextBytes: this.findingsOptions.maxContextBytes,
// The answer-turn ask (9.103.0): the same value seed records, so the
// wire and the rebuild append it from one fact; absent on `'none'`.
...(this.findingsOptions.answerAsk === 'quote-facts' && {
findingsAnswerAsk: 'quote-facts',
}),
}),
// Declared sources (honesty layer 2): without the ledger, the choice
// seam peels `_findings` where the served schema carries it (ruled
// tools only) — value-conditional, the same grammar.
...(argumentSources && { argumentSources: true }),
...((this.answerValidationConfig !== undefined || governsOutput) && {
suppressDraftTokens: true,
withholdDraftContent: true,
}),
// Tool choice by classifier (9.105.0): the outcome row after the reply.
...(this.toolChoiceOptions !== undefined && { toolChoice: true }),
// The declared ontology (9.106.0): the piece served from the run
// constant on every call, under the one gate — an unarmed agent reads
// no new key.
...(this.ontology !== undefined && { ontology: true }),
// The time layer's served line (step T6b): read wherever the Tools mount
// composes it — under `.time()` (G14: the control window needs no inputs
// layer). Step T8: the same line carries the turn's time limits.
...(this.timeOptions !== undefined && { timeLine: true }),
// The receipt's salt (9.88.0) — read per call, like seed's own accessor.
getRunId: () => this.currentRunContext?.runId,
// …and its off switch. Value-conditional, so an agent on the default
// hands `callLLM` exactly the dep bag it always did.
...(this.recordReceiptValue === false && { recordReceipt: false }),
// …and the window's evictions for `omittedForAttention` (9.93.0) — only
// an agent with a window strategy has any, and only it hands the seam.
...(this.evictedTurnsHandle !== undefined && { evictedTurns: this.evictedTurnsHandle }),
// …and the receipt memo, so a run hashes each message once rather than
// once per call (`core/agent/receipt/receiptDigests.ts`). Only where a
// receipt is minted at all.
...(this.recordReceiptValue !== false && { receiptDigests: this.receiptDigests }),
provider,
model,
...(temperature !== undefined && { temperature }),
...(maxTokens !== undefined && { maxTokens }),
...(pricingTable !== undefined && { pricingTable }),
...(costBudget !== undefined && { costBudget }),
maxIterations,
cacheStrategy,
...(this.cacheStrategyIsOverride && { cacheStrategyIsOverride: true }),
get toolSchemas() {
return toolSchemasResolved;
},
// The declared argument-ground edges (9.60.0) — value-conditional, so
// an agent whose tools declare none runs the exact bytes it always did.
...(toolGrounding.size > 0 && { toolGrounding }),
// The staged-refs nudge (grounded numbers) — armed only when the dial
// is ON and a registered tool declares `wants`; otherwise the stage
// reads nothing new and every request keeps its exact bytes.
...(this.evidenceGate?.nudge === true && toolWants.size > 0 && { toolWants }),
// The external-ground door (9.72.0) — value-conditional for the same
// reason: no provider, no key, byte-identical corpus assembly.
...(this.externalGrounds !== undefined && { externalGrounds: this.externalGrounds }),
// The write-seam advisory's arming (9.77.0) — this stage owns only the
// "no armed call this response" not-applicable note; the check itself
// runs where the result is. Value-conditional on BOTH halves, so an
// agent that never asked for it reads no new key.
...(this.noticeEmptyLookups && toolGrounding.size > 0 && { noticeEmptyLookups: true }),
// The column-type contract's arming (9.78.0), same job and same
// value-conditional law: this stage owns only the "no declaring tool
// was called this response" not-applicable notes.
...(this.checkColumnTypes !== 'off' &&
toolColumns.size > 0 && { columnDeclaringTools: new Set(toolColumns.keys()) }),
integrityLedger: this.integrityLedgerHolder,
...(this.reliabilityConfig !== undefined && { reliability: this.reliabilityConfig }),
...(this.outputSchemaParser !== undefined && {
outputSchemaParser: this.outputSchemaParser,
}),
// 7.26 — the synthetic tool, under `strategy: 'tool-forced'` only.
...(this.outputEnforcement?.schemaTool !== undefined && {
schemaTool: this.outputEnforcement.schemaTool,
}),
// The native format candidate; auto selects it for the effective request.
...(this.outputEnforcement?.outputFormat !== undefined && {
outputFormat: this.outputEnforcement.outputFormat,
...(this.outputEnforcement.nativeOutputAuto === true && {
nativeOutputAuto: true,
}),
}),
...(this.thinkingBudget !== undefined && { thinkingBudget: this.thinkingBudget }),
// Only a configured agent reads `scope.resolvedModel` — see the dep's
// JSDoc for why this is a build-time flag and not a runtime fallback.
...(this.runConfigFn && { runConfigured: true }),
// Opt-in system-prompt capture (9.50.0) — value-conditional, so an agent
// that never asked keeps byte-identical llm_start events.
...(this.recordSystemPromptValue && { recordSystemPrompt: true }),
// "The cursor picks the brain" (9.19.0) — wired only when a per-skill
// brain or an escalation exists, so every other agent's stage reads no
// new scope key and resolves on the exact line it always did. The
// per-brain cache strategies resolve HERE, once, where the agent's own
// strategy (override included) is known: a brain on the agent's own
// provider keeps it, any other provider gets the strategy its declared
// `promptCaching` selects (markers are provider-aware — the one
// genuinely risky seam, resolved statically).
...(this.skillBrains !== undefined &&
(this.skillBrains.bySkill.size > 0 || this.skillBrains.escalation !== undefined) && {
brainFor: buildBrainFor({
brains: this.skillBrains,
agentProvider: provider,
agentCacheStrategy: cacheStrategy,
agentModel: model,
...(this.cacheStrategyIsOverride && { agentCacheStrategyIsOverride: true }),
}),
}),
});
// Window stage (7.16 as compaction; the strategy family since 7.17) —
// built ONLY when `.window()` / `.compaction()` was called. When present
// it becomes the ReAct loop target (see the chart builders), so it runs
// once per iteration boundary with the previous call's adapter-reported
// usage already measured by the meter.
const windowStage = this.windowStrategy && this.compactionMeterHandle
? {
strategyName: this.windowStrategy.name,
run: buildWindowStage({
strategy: this.windowStrategy,
meter: this.compactionMeterHandle,
...(this.evictedTurnsHandle !== undefined && {
evictedTurns: this.evictedTurnsHandle,
}),
agentModel: model,
providerName: provider.name,
getRunId: () => this.currentRunContext?.runId,
// Value-conditional (the `repeatedCallNudge` precedent): an
// agent on the default hands the stage exactly the deps object
// it always did.
...(this.keepLastToolResults !== undefined && {
keepLastToolResults: this.keepLastToolResults,
}),
// The findings ledger (9.102.0): the arm and the RESOLVED
// ledger-fact ceiling, together or not at all. `keepLedgerFacts`
// is set exactly when `.findings()` is on, so an unarmed agent
// hands the stage exactly the deps object it always did; an
// armed agent with no window strategy threads nothing, because
// this whole stage does not exist for it.
...(this.keepLedgerFacts !== undefined && {
hasFindingsLedger: true,
keepLedgerFacts: this.keepLedgerFacts,
}),
...(pricingTable !== undefined && { pricingTable }),
...(costBudget !== undefined && { costBudget }),
}),
}
: undefined;
// routeDecider extracted to ./agent/stages/route.ts (v2.11.2).
// The Route decider carries the `'output'` half of the message chain when
// one is configured — see buildRouteDeciderStage for why that seam and not
// PrepareFinal. Without a chain this is the same function reference the
// chart has always been handed.
// 7.26 — the decider also judges the final answer against the schema when
// the agent opted into enforcement, because it is the last stage that
// still has a loop to send the answer back around. Without enforcement
// this is the same function reference the chart has always been handed.
// 9.18.0 — the decider also judges a would-be-final answer against the
// active step procedure (nudge once / accept / cut-short) when a stepped
// skill is registered. Without steps this is the same function reference
// the chart has always been handed.
// 9.35.0 — and the decider grounds the would-be-final answer in the
// turn's own tool results when `.namesAndNumbersFromEvidence()` is
// configured. Without it this is the same function reference the chart
// has always been handed.
// ── The out-of-budget wrap-up (9.56.0) — one build-time decision ───
// Mounted when the agent can CALL a tool and did not opt out. The tool
// test is not a nicety: a limit only cuts a turn short when tool calls
// were pending (`decideBranch`'s `earlyStop`), so an agent with nothing to
// call can never reach the branch — and mounting a node that can never run
// would put a WrapUp box on every toolless agent's chart, in every
// recording, forever. A `ToolProvider` counts even though its list is only
// known per iteration: it might list one, and "might" is the whole
// question a build-time mount can answer.
const canCallTools = registryByName.size > 0 || this.externalToolProvider !== undefined;
const hasWrapUp = canCallTools && this.wrapUpAtMaxIterations !== false;
const baseRouteDecider = buildRouteDeciderStage(this.messageMiddleware, this.outputEnforcement, stepPlanFor, this.evidenceGate, hasWrapUp, this.claimContract, this.integrityLedgerHolder,
// THE CLAIM SEAM'S RECENCY READ (9.83.0). Value-conditional on both
// halves, so an agent that armed neither hands the decider builder
// exactly the arguments it always did — and `buildRouteDeciderStage`'s
// no-judge fast path still returns the very function reference every
// pre-9.83.0 chart was given.
this.noticePriorTurnEvidence && this.evidenceGate !== undefined ? true : undefined,
// THE ANSWER TURN'S STANDINGS (9.101.0) — the same value-conditional
// trailing positional, for the same reason: an unarmed agent hands the
// builder exactly the arguments it always did.
this.findingsOptions !== undefined ? true : undefined,
// THE INPUTS LAYER (honesty layer 2) — the same value-conditional
// trailing positional: the rows this decider files carry the turn
// stamp, and with the evidence gate armed too, the gate's exempt corpus
// gains this turn's assumed values, read from the tools' declarations,
// and the values the person gave the layer's batch ask (step 4). The
// results layer (step 7b) arms the stamp alone — "while any layer is
// armed, the one writer stamps the turn" — and nothing else.
inputsArmed || resultsArmed
? {
...(inputsArmed &&
this.evidenceGate !== undefined && {
declaredDefaults: declaredDefaultsFrom((toolName, argument) => {
const rules = rulesOf(resolveForLayer(toolName).tool);
if (rules === undefined || isRefused(rules))
return undefined;
return rules.ruled.find((r) => r.argument === argument)?.assume;
}),
answeredValues: answeredValuesOf,
// Under `.time()` (step T7): the turn's time spellings by
// lineage — said ones exempt, derived ones filed, not flagged.
...(this.timeOptions !== undefined && { timeLineage: true }),
}),
}
: undefined,
// THE ANSWER LAYER (honesty layer 4) — the same value-conditional
// trailing positional: the judges file the answer's witness rows, and
// every row this decider files carries the turn stamp.
answerArmed ? true : undefined);
const validatedRouteDecider = this.answerValidationConfig === undefined
? baseRouteDecider
: withAnswerValidation(baseRouteDecider, this.answerValidationConfig, this.outputSchemaParser, artifactStore, () => this.consentOutstanding.size > 0, this.outputEnforcement?.outputFormat !== undefined,
// Read only when `agentChecks` lists 'claims' (the agent's own rows).
this.claimContract, this.typedNegativeClaimsConfig === undefined
? undefined
: {
config: this.typedNegativeClaimsConfig,
phrases: this.typedNegativeClaimsConfig.phrases,
applicationValidation: this.applicationAnswerValidation,
runId: () => this.currentRunContext.runId,
});
const routeDecider = this.negativeClaimsConfig?.mode === 'prose'
? withNegativeProse(validatedRouteDecider, {
runId: () => this.currentRunContext.runId,
hasOutstandingConsent: () => this.consentOutstanding.size > 0,
structured: this.outputSchemaParser !== undefined,
phrases: this.negativeClaimsConfig.phrases,
})
: validatedRouteDecider;
// `.limitsTravelWithTheAnswer()` on a TYPED answer — decided once, here.
// An output schema makes the answer JSON, and the appended block would
// make it JSON followed by prose, which `runTyped()` cannot parse. So the
// limits travel as DATA: the Route decider's terminal decision commits the
// fold (`withAnswerCoverage`) and the Final branch projects it onto
// `turn_end`. A prose answer keeps the block; an agent that asked for
// neither is handed the decider it always had.
const limitsAsData = this.limitsTravelWithTheAnswerValue && this.outputSchemaParser !== undefined;
// With the inputs layer armed, the typed answer's limits carry this
// turn's assumed values too — the same rows the prose "Assumed" block
// prints (`withAnswerCoverage`'s second argument); a rewrite by a
// before-tool chain is read only when the agent has one.
const terminalRouteDecider = limitsAsData
? withAnswerCoverage(routeDecider, inputsArmed ? { rewrites: this.toolMiddleware.length > 0 } : undefined)
: routeDecider;
// toolCallsHandler extracted to ./agent/stages/toolCalls.ts (v2.11.2).
const toolCallsHandler = buildToolCallsHandler({
registryByName,
// WHICH SKILLS DECLARE EACH TOOL NAME (9.86.0) — read by the unknown-tool
// roster so a role that may not see a skill is not told the names of the
// tools that skill brought. Value-conditional: an agent whose skills carry
// no tools hands the handler exactly the deps object it always did.
...(toolDeclaringSkills.size > 0 && { toolDeclaringSkills }),
// The claim ledger accumulates only for an agent that declared a
// contract to read it (9.61.0) — value-conditional, so every other
// agent commits exactly what it always did.
...(this.claimContract !== undefined && { collectClaimFacts: true }),
// The findings ledger (9.101.0) — the peel, the basis row and the
// previous batch's standings all live in this handler under this gate.
...(this.findingsOptions !== undefined && { findings: true }),
// The script record — `code: 'match'` or `.scriptTravelsWithTheAnswer()`:
// one `script` row per call of a tool that declares `Tool.script`.
...(scriptRecordArmed && { scriptRecord: true }),
// The inputs layer (honesty layer 2) — the fills, the refusals and the
// note; absent → a ruled tool's call is refused (fail closed).
...(inputsArmed && { inputsLayer: true }),
// Declared sources: the peel reads `_findings.from` as the layer did.
...(argumentSources && { argumentSources: true }),
// The results layer (honesty layer 3, step 7b) — read in one place: a
// period a result declares with NO layer mounted is dev-warned (nobody
// will file its verdict). And the turn stamp, as under any layer.
...(resultsArmed && { resultsLayer: true }),
// The answer layer (honesty layer 4) — only the turn stamp on the rows
// this handler files (adopted Q6).
...(answerArmed && { answerLayer: true }),
// …and the host's own context for the layer's batch ask (step 4),
// value-conditional inside the arm.
...(inputsArmed &&
this.argumentAskContext !== undefined && { argumentAskContext: this.argumentAskContext }),
// The judge (9.104.0) — VALUE-conditional inside the arm: an armed
// agent without one hands the handler exactly the deps it did before.
...(this.findingsOptions?.judge !== undefined && {
findingsJudge: this.findingsOptions.judge,
}),
// THE TOWERS AT DISPATCH (9.110.0) — the contingent check's second
// moment. VALUE-conditional on BOTH doors: the ledger (the standings)
// and the evidence gate (the corpus and the extractor's rule); an
// agent with one or neither hands the handler exactly the deps it did
// before. The same resolved gate the route decider judges the answer
// with, so the two moments read one extractor and one set of shapes.
...(this.findingsOptions !== undefined &&
this.evidenceGate !== undefined && { evidenceGate: this.evidenceGate }),
// THE WRITE SEAM (9.77.0) — `empty-lookup`. Handed the SAME harvested
// map callLLM reads at the choice seam, so the two stages agree by
// construction about which calls are armed. Value-conditional on both
// halves — the operator's dial and at least one `argumentsFrom`
// declaration — so an agent that asked for neither hands the handler
// exactly the deps object it always did.
...(this.noticeEmptyLookups &&
toolGrounding.size > 0 && {
emptyLookupGrounding: toolGrounding,
integrityLedger: this.integrityLedgerHolder,
}),
// THE WRITE SEAM'S other check (9.78.0) — the column-type contract.
// Value-conditional on both halves for the same reason, so an agent
// that asked for neither hands the handler exactly the deps object it
// always did.
...(this.checkColumnTypes !== 'off' &&
toolColumns.size > 0 && {
columnDeclarations: toolColumns,
columnCheckMode: this.checkColumnTypes,
integrityLedger: this.integrityLedgerHolder,
}),
...(this.externalToolProvider && { externalToolProvider: this.externalToolProvider }),
...(this.externalToolProvider && { providerToolCache }),
servedTools,
toolClaimants,
...(permissionChecker && { permissionChecker }),
...(credentialProvider && { credentialProvider }),
// The request a call's credential is read for: this agent's own run's,
// or — on a mount — the run its link names (`memberRun.ts`). An accessor,
// because the chart is built once and the request is per run.
...(credentialProvider && {
currentRequest: (scope) => this.ownScopes.has(scope) ? this.scopeRequests.get(scope) : memberRequestIn(scope),
}),
// The claim-check store (9.21.0). Absent → not one new line runs in
// dispatch: `ctx.artifacts` is the fail-closed teacher and no artifact
// event can fire (zero-cost-when-unused).
...(artifactStore && { artifactStore }),
// The placement threshold (9.22.0) — only ever set beside a store (the
// option's own shape enforces it). Absent → results are never measured
// against it and never placed.
...(this.artifactPlacement !== undefined && { placement: this.artifactPlacement }),
...(this.toolArgValidation && { toolArgValidation: this.toolArgValidation }),
...(this.maxToolResultChars !== undefined && {
maxToolResultChars: this.maxToolResultChars,
}),
// The repeated-call nudge (9.26.0). Threaded ONLY when switched off —
// the VALUE-conditional pattern, so an agent on the default hands the
// handler exactly the deps object it always did.
...(this.repeatedCallNudge === false && { repeatedCallNudge: false }),
// The answer check's tool-call ledger (`context.toolCalls`) — kept ONLY
// for answer validation or negative claims; other records stay unchanged.
...((this.answerValidationConfig !== undefined ||
this.negativeClaimsConfig !== undefined) && {
toolCallLedger: { maxCalls: this.answerValidationConfig?.limits.maxToolCalls ?? 1000 },
}),
...(this.negativeClaimsConfig !== undefined && { negativeClaimsArmed: true }),
// Skill-graph read_skill gate: bound the model's read_skill jumps to the
// reachable set from the current cursor. Undefined → gate off (back-compat).
...(this.skillGraphReachable && {
allowedSkillIds: this.skillGraphReachable,
openSkillIds: this.openSkillIds(),
skillGraphIsTree: this.skillGraphIsTree,
}),
// The mount kernel's plan (9.59.0) — the gate's THIRD admission class.
// A `read_skill` pick of a PARKED map's member is a re-engagement, not
// a hop: admitted even though the reachable set excludes the node the
// cursor already stands on, and it never touches the cursor. Value-
// conditional, so an agent without `.maps()` hands the handler exactly
// the deps object it always did.
...(this.mapsPlan !== undefined && { engagementPlan: this.mapsPlan }),
// The mount's routing posture (SG-C). `'assist'` — the default — is
// deliberately NOT threaded: undefined keeps the gate byte-identical.
...(this.skillGraphCascade !== undefined &&
this.skillGraphCascade.strictness !== 'assist' && {
skillStrictness: this.skillGraphCascade.strictness,
}),
// Steps (9.18.0): advance/skip at the result boundary — the batch loop
// AND every pausable resume path (an askHuman step advances on resume).
...(stepPlanFor !== undefined && { stepPlanFor }),
// Escalate-on-evidence (9.19.0): the refusal budget + flip, wired only
// when the policy exists. `describeFrom` resolves the event's honest
// `from` by the same chain callLLM applies.
...(this.skillBrains?.escalation !== undefined && {
escalation: {
afterRefusals: this.skillBrains.escalation.afterRefusals,
to: {
provider: this.skillBrains.escalation.provider.name,
...(this.skillBrains.escalation.model !== undefined && {
model: this.skillBrains.escalation.model,
}),
},
describeFrom: (cursor, resolvedModel) => describeServingBrain({
brains: this.skillBrains,
cursor,
agentProviderName: provider.name,
defaultModel: model,
...(resolvedModel !== undefined && { resolvedModel }),
}),
},
}),
// The require-instruction catalog (9.19.0): every registered injection
// id + the one delivery fact its check-up needs. Wired whenever
// injections exist; nothing runs until a tool returns an effect.
...(this.injections.length > 0 && {
leaseTargets: new Map(this.injections.map((inj) => {
const surfaceMode = inj.metadata?.surfaceMode;
return [inj.id, { ...(surfaceMode !== undefined && { surfaceMode }) }];
})),
}),
// Check-in (evidence-carrying human consent). Always threaded (resolved
// default); the gate fires only for tools that declared `checkIn`.
checkIn: this.checkInConfig,
// Where a decision's request is spent, once (`core/consentRequest.ts`).
// Absent → this process's ledger, shared by every agent that names none.
...(this.decisionLedger !== undefined && { decisionLedger: this.decisionLedger }),
// The governance chain. Threaded only when non-empty so an agent without
// one produces the same handler behaviour it always did.
...(this.toolMiddleware.length > 0 && { toolMiddleware: this.toolMiddleware }),
// Durable-write barrier. An ACCESSOR, never a captured value: the chart is
// built once at construction and a session composer installs its barrier
// later, so a direct field read here would be stale forever. Answers
// `undefined` — no await, no microtask — until one is installed.
awaitDurable: () => pendingDurableWrite(this),
// 9.7.0 — run/session identity and the teardown registrar. BOTH are
// accessors for the reason `awaitDurable` is one: the chart is built once
// at construction, and a captured value would be run #1's forever. The
// tier one is lazy on top of that — an agent whose tools hold no sessions
// never allocates it.
currentRun: () => this.toolRunFacts(),
isOwnRun: (scope) => this.ownScopes.has(scope),
answeredAsk: () => this.answeredAsk,
// The time layer: each dispatched call files its `call` row, and the
// resume door compares a resume's passed `time` with the kept clock.
...(this.timeOptions !== undefined && {
time: {
takePassedOnResume: (scope) => {
// Only the agent's own resumed leg takes it — never a member's.
if (!this.ownScopes.has(scope))
return undefined;
const passed = this.resumePassedTime;
this.resumePassedTime = undefined;
return passed;
},
...(this.timeOptions.zone !== undefined && { appZone: this.timeOptions.zone }),
// The reader (step T6b): the batch ask asks an open mention's window.
...(this.timeOptions.reader !== undefined &&
this.timeOptions.policy !== undefined && {
reader: {
policy: this.timeOptions.policy,
...(this.timeOptions.messages !== undefined && {
messages: this.timeOptions.messages,
}),
},
}),
},
}),
emitForRun: (type, payload, runContext) => this.emitLateFact(type, payload, runContext),
toolSessions: () => this.toolSessions(),
// 8.6.0 — what a run does when a declared credential needs 3LO consent.
onAuthorizationRequired: this.onAuthorizationRequired,
// The `'tell-model'` consent record travels OFF tracked state (a tracked
// write is a commit-log entry, and the URL is a bearer capability), so
// these two callbacks are its whole route to the caller. Cleared at the
// top of every run; read in `finalizeResult`.
reportConsentOutstanding: (record) => {
this.consentOutstanding.set(record.service, record);
},
clearConsentOutstanding: (service) => {
this.consentOutstanding.delete(service);
},
});
// v2.14 — Build the NormalizeThinking sub-subflow only when a
// ThinkingHandler resolved (declared by the provider OR
// explicitly set via .thinkingHandler()). Conditional mount ensures
// zero overhead for non-thinking agents — the chart has zero extra
// stages when undefined.
const thinkingSubflow = this.thinkingHandler
? buildThinkingSubflow(this.thinkingHandler, this.provider.name)
: undefined;
// Chart composition extracted to ./agent/buildAgentChart.ts (v2.11.2).
// The deps object is identical for both chart shapes — only the
// wiring differs (flat call-llm stage vs sf-llm-call subflow).
const chartDeps = {
memories: this.memories,
// Evidence bridge (#5): closure hand-off to the CAUSAL write mounts.
...(this.causalEvidence && { causalEvidenceSource: this.causalEvidence.collect }),
systemPromptCachePolicy,
maxIterations,
seed,
callLLM,
routeDecider: terminalRouteDecider,
toolCallsHandler,
// The re-ask branch — mounted only when there are retries to spend. A
// `'tool-forced'` agent with `retries: 0` gets the constrained shape and
// no branch: there is nothing for a branch to do.
...(this.outputEnforcement !== undefined &&
this.outputEnforcement.retries > 0 && {
outputRetryStage: buildOutputRetryStage(this.outputEnforcement),
}),
// The unfinished-steps nudge branch (9.18.0) — mounted only when a
// stepped skill exists, same conditional-mount law as the re-ask above.
...(stepPlanFor !== undefined && {
hasSteps: true,
stepNudgeStage: buildStepNudgeStage(stepPlanFor),
}),
// The maps kernel (9.58.0) — gates the mapEngagement alias round trip
// through the engine boundary. Same conditional-mount law as steps.
...(this.mapsPlan !== undefined && { engagementPlan: this.mapsPlan }),
// The evidence gate (9.35.0). `hasEvidenceGate` rides ANY posture (the
// grouped chart needs the system-prompt records bubbled out to exempt
// what the app itself supplied); the BRANCH is mounted only for a
// posture that can revise — `'assist'` records and never loops, so it
// gets no branch and no stage, the same conditional-mount law as above.
...(this.evidenceGate !== undefined && {
hasEvidenceGate: true,
...(this.evidenceGate.posture !== 'assist' && {
evidenceRecheckStage: buildEvidenceRecheckStage(this.evidenceGate,
// The staged-refs join (grounded numbers) — the correction names
// the refs and the spender when the run declared both. Threaded
// whenever a `wants` tool exists (not gated on `nudge`: a
// revision that cannot say HOW to compute leaves the model to
// head-math again); absent for every agent without one, so the
// correction keeps its exact bytes.
toolWants.size > 0
? {
toolWants,
staticToolNames: () => toolSchemasResolved.map((t) => t.name),
}
: undefined),
}),
}),
// Escalation (9.19.0): the grouped chart threads `skillEscalated`
// across the sf-llm-call boundary only when the policy exists.
...(this.skillBrains?.escalation !== undefined && { hasEscalation: true }),
// The findings ledger (9.102.0): both charts compute the OFFER on the
// Tools branch's mount only under the arm — the same value-conditional
// grammar as the slot's `findings` and callLLM's `hasFindingsLedger`,
// so the three can never disagree about whether the ledger is armed.
...(this.findingsOptions !== undefined && { hasFindingsLedger: true }),
// The inputs layer (honesty layer 2): `sf-inputs` between the LLM call
// and Route, in both builders through one helper; absent → untouched.
// With a before-tool chain, the final branch's "Assumed" block also reads
// the chain's rewrites (a rewritten fill is not what the call ran with).
...(inputsArmed && {
inputsLayer: {
...(this.findingsOptions !== undefined && { findings: true }),
// Route's sixth dispatch value, read only by an agent whose revision
// turns the tools off (an enforcing answer validation).
...(this.revisionWithholdsTools() && { toolsOffAfterRevision: true }),
toolOf: (toolName) => resolveForLayer(toolName).tool,
...(this.toolMiddleware.length > 0 && { rewrites: true }),
// The time layer (step T5a): a tool whose period declares forms is filled from the
// turn's one window, bound to the person's window, or recorded as the model's.
...(this.timeOptions !== undefined && {
time: {
...(this.timeOptions.zone !== undefined && { appZone: this.timeOptions.zone }),
},
}),
// Declared sources: the layer reads `_findings.from` and checks it against
// the corpora the mount hands in — the app's `externalGrounds` among them.
...(argumentSources && {
sources: {
...(this.externalGrounds !== undefined && {
externalGrounds: this.externalGrounds,
}),
// Beside the findings ledger a declared basis files a basis row,
// which carries the dropped `from` entries' count; without it the
// count rides the call's first argument row.
...(this.findingsOptions !== undefined && { basisRows: true }),
// A tool in reach that may hide an argument keeps every quote off the
// record — a quote is free text that may hold the hidden value. Decided
// once, here, over every party the layer's `toolOf` can resolve: the
// registry (each tool asked), and a ToolProvider — whatever it lists,
// since its list is known only per iteration and a tool it lists first
// AFTER a quote was filed would find that quote already on the record.
...(([...registryByName.values()].some(carriesArgumentView) ||
this.externalToolProvider !== undefined) && {
argumentViews: true,
}),
},
}),
},
}),
// The results layer (honesty layer 3, step 7b): `sf-results` at the loop
// head, in both builders through one helper, reading each call's
// `ToolPeriod` off the implementation that answered it; absent → untouched.
...(resultsArmed && {
resultsLayer: { toolOf: (toolName) => resolveForLayer(toolName).tool },
}),
// The answer layer (honesty layer 4): its stage heads the final branch
// in both builders through one helper, reading only the committed keys
// this agent's arms can write; absent → the branch is untouched.
...(this.answerLayerOption !== undefined && {
answerLayer: {
reads: answerFoldReads({
tools: canCallTools,
evidenceGate: this.evidenceGate !== undefined,
answerValidation: this.answerValidationConfig !== undefined,
inputs: inputsArmed,
toolMiddleware: this.toolMiddleware.length > 0,
}),
...(this.answerLayerOption.standingLine !== undefined && {
standingLine: this.answerLayerOption.standingLine,
}),
...(this.answerValidationConfig !== undefined && { validated: true }),
...(displayNameOf !== undefined && { displayNameOf }),
},
}),
// The prose limits block names an item's calls in the app's words — under the same wiring.
...(displayNameOf !== undefined && { displayNameOf }),
// Tool choice by classifier (9.105.0): the mount args on the Tools
// branch and the key across the sf-llm-call boundary, under the arm.
...(this.toolChoiceOptions !== undefined && { hasToolChoice: true }),
// The declared ontology (9.106.0): the grouped chart carries the run
// constant across the sf-llm-call boundary, under the arm.
...(this.ontology !== undefined && { hasOntology: true }),
// `.limitsTravelWithTheAnswer()` — value-conditional, the
// `resolvedModel` precedent: absent from the deps object entirely for an
// agent that did not ask, so both builders mount the final-branch stage
// function they have always mounted. ONE of the two, never both: a prose
// answer gets the appended block, a TYPED one (`limitsAsData`, above)
// gets the stage that appends nothing and carries the limits as data.
...(this.limitsTravelWithTheAnswerValue && !limitsAsData && { attachCoverageLimits: true }),
...(limitsAsData && { coverageLimitsAsData: true }),
// `.scriptTravelsWithTheAnswer()` — the recorded programs travel with the
// answer: data always, text on a prose answer only. Absent → the final
// stage this agent always mounted.
...(this.scriptTravel !== undefined && {
scriptTravel: {
...this.scriptTravel,
...(this.outputSchemaParser !== undefined && { typed: true }),
},
}),
// The time layer: the prose limits block renders its `Period:` lines in
// the run's clock zone. Absent → the final stage it always mounted.
...(this.timeOptions !== undefined && { timeLayer: true }),
// The Tools mount hands the slot the turn's windows — the reader's (step T6b) and the
// app's control window (G14) — under `.time()`.
...(this.timeOptions !== undefined && { timeWindows: true }),
...(inputsArmed && this.timeOptions !== undefined && { timeLimits: true }),
...(this.answerValidationConfig !== undefined && { hasAnswerValidation: true }),
...(this.typedNegativeClaimsConfig !== undefined && {
negativeRecheckStage: negativeRecheckStage,
}),
...(governsOutput && { releaseOutputTokens: true }),
...(this.evidenceGate?.posture === 'rails' && { evidenceRails: true }),
// The out-of-budget wrap-up branch (9.56.0) — the conditional-mount law
// above, decided once beside the Route decider that routes to it so the
// two can never disagree about whether the branch exists.
...(hasWrapUp && { wrapUpStage: wrapUpStage }),
injectionEngineSubflow,
...(pickEntryStage && { pickEntryStage }),
...(routeTurnStage && { routeTurnStage: routeTurnStage }),
// Messages-slot delivery (7.21) — mounted ONLY when something could
// target the slot. A registered injection declaring `inject.messages`
// is the obvious case; a `.memory()` is the other, because a hand-built
// read subflow may format its recall as a non-system role and that
// recall only exists at run time. An agent with neither gets no stage
// and no write, so its chart and its commit log are what they were.
...((this.injections.some((i) => (i.inject?.messages?.length ?? 0) > 0) ||
this.memories.length > 0) && {
deliverStage: buildDeliverStage({
provider: this.provider,
...(this.reliabilityConfig?.providers !== undefined && {
providers: providerTargets(this.provider, this.model, this.reliabilityConfig.providers).map((target) => target.provider),
}),
memoryIds: this.memories.map((m) => m.id),
}),
}),
systemPromptSubflow,
messagesSubflow,
toolsSubflow,
...(thinkingSubflow !== undefined && { thinkingSubflow }),
updateSkillHistoryStage,
...(windowStage !== undefined && { windowStage }),
// Gate the UpdateSkillHistory stage on skills being registered —
// same idiom buildToolRegistry uses to auto-attach `read_skill`.
hasSkills: this.injections.some((i) => i.flavor === 'skill'),
// Builders only branch on classic-vs-dynamic SEMANTICS; the grouped
// chart shape is selected below by choosing buildDynamicAgentChart.
reactMode: (this.reactMode === 'classic' ? 'classic' : 'dynamic'),
...(this.structureRecorders !== undefined && {
structureRecorders: [...this.structureRecorders],
}),
};
// `'dynamic-grouped'` wraps the whole LLM turn in an `sf-llm-call` subflow —
// the same boundary LLMCall produces — so Lens / explainable-ui render it as
// an LLM group with its slots inside. `'classic'` and `'dynamic'` use the
// flat chart; they differ only in `chartDeps.reactMode` (whether the Context
// selector re-engineers the static slots each turn). Grouping is dynamic-only
// (it re-seeds context every turn by design), so there is no classic-grouped.
return this.reactMode === 'dynamic-grouped'
? buildDynamicAgentChart(chartDeps)
: buildAgentChart(chartDeps);
}
}
// AgentBuilder extracted to ./agent/AgentBuilder.ts (v2.11.2).
// Re-export so the 28+ existing import sites continue to work unchanged.
// Validators + helpers extracted to ./agent/validators.ts (v2.11.1).
/** How many run ids one instance remembers. */
const MAX_REMEMBERED_RUNS = 256;
/** Drop the oldest entries of an insertion-ordered Set or Map past `max`. */
function boundInsertionOrder(held, max) {
while (held.size > max) {
const oldest = held.keys().next();
if (oldest.done === true)
return;
held.delete(oldest.value);
}
}
/**
* The run options without the time layer's `time` — what the engine is handed.
* The SAME object when it carries no `time` key, so a run that passed none
* hands the executor exactly the options it always did.
*/
function withoutTime(options) {
if (options === undefined || !('time' in options))
return options;
const { time: _time, ...rest } = options;
void _time;
return rest;
}
/**
* The policy a paused agent run was covered by, read off its own checkpoint
* (`AgentState.runRedaction`, committed by seed and by every resumed leg) —
* FAIL CLOSED: a value this library did not write, or a checkpoint whose run
* kept names out of its records (`redactionMarks`) but carries no policy,
* refuses the resume (`ResumeRedactionError`) before anything moves. A leg its
* policy could not keep covered must never start.
*/
function resumeRedactionOf(checkpoint, trusted) {
const state = checkpoint.sharedState;
let carried;
try {
// A carried pattern is a reference to one the resuming side holds — never
// compiled from the checkpoint (`redaction/policy.ts` · `policyFromCarried`).
carried = policyFromCarried(state?.runRedaction, 'Agent.resume', trusted);
// A policy built under ANOTHER vocabulary version is joined with the
// current list — the leg is never covered by an older list alone.
carried = withCurrentVocabulary(carried, carriedVocabularyOf(state?.runRedaction));
}
catch (refusal) {
throw new ResumeRedactionError(refusal instanceof CarriedPolicyError && refusal.refusal === 'unknown-pattern'
? 'unknown-pattern'
: 'unreadable');
}
// Every leg of a covered run writes its policy into its checkpoint's state
// (seed, and `resume` for a resumed leg), so a checkpoint whose run kept
// names out but carries none was altered — refused even when the resume
// names a policy, which cannot stand in for the one the run was covered by.
if (carried === undefined && policyOfMarks(checkpoint.redactionMarks) !== undefined) {
throw new ResumeRedactionError('missing');
}
return carried;
}
//# sourceMappingURL=Agent.js.map