agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
3,911 lines • 220 kB
JavaScript
import { findingsInstruction } from './findings/instruction.js';
import { findingsContextBytes } from './findings/context.js';
/**
* AgentBuilder — fluent builder for Agent. Extracted from Agent.ts in
* v2.11.2 as part of the core/agent decomposition. Same surface, same
* behavior; just lives in its own file for readability.
*
* Re-exported by Agent.ts so external consumers importing
* `AgentBuilder` from `'../core/Agent.js'` continue to work.
*/
import { isDevMode } from 'footprintjs';
import { resolveAnswerValidation, } from '../../answer-validation/index.js';
import { buildDefaultInstruction, } from '../../answer-validation/outputSchema.js';
import { buildSchemaTool, resolveJsonSchema, SCHEMA_TOOL_NAME, } from './outputEnforcement.js';
import { nativeOutputFormat, NativeOutputSchemaError } from './nativeOutputFormat.js';
import { resolveNegativeClaims, assertNegativeClaimsTools, negativeClaimsSchema, negativeClaimsInstruction, } from './negativeClaims/config.js';
import { resolveEvidenceGate } from './evidence/gate.js';
import { scopeToolsToActiveSkill } from './toolsFromActiveSkill.js';
import { validateCannedAgainstSchema, } from '../outputFallback.js';
import { SKILL_GRAPH_DEFERRED_CONTRACT_KEY, } from '../../injection-engine/skillGraph.js';
import { foldSkillBrains, } from './skillBrains.js';
import { checkThinkingSupport } from './thinkingSupport.js';
import { defineMenuHint, MENU_HINT_METADATA_KEY, } from '../../injection-engine/factories/defineMenuHint.js';
import { defineStepsHint, STEPS_HINT_METADATA_KEY, } from '../../injection-engine/factories/defineStepsHint.js';
import { foldStepPlans } from '../../injection-engine/skillSteps.js';
import { checkSkillContracts, skillToolNames } from '../../injection-engine/skillContract.js';
import { formatCheckup } from '../../injection-engine/skillGraphCheckup.js';
import { checkArtifactVocabularies } from '../../injection-engine/skillVocabulary.js';
import { toolOnlyDeliveryRefusal } from '../../injection-engine/skillBodyDelivery.js';
import { defineInstruction } from '../../injection-engine/factories/defineInstruction.js';
import { messagesToolRoleRefusal } from '../../injection-engine/messagesSlotRefusal.js';
import { READS_ENTRY_SCORES_METADATA_KEY } from '../../injection-engine/factories/defineRelevanceHint.js';
import { skillScopedToolsTarget } from '../../tools/skillScopedTools.js';
import { defaultCommentaryTemplates } from '../../recorders/observability/commentary/commentaryTemplates.js';
import { defaultStatusTemplates } from '../../recorders/observability/status/statusTemplates.js';
import { requireSelfExplain } from './selfExplainPort.js';
import { servingConversationOf } from './servingConversation.js';
import { innerRunsOf, mergeInnerRuns } from '../innerRunRecords.js';
import { TRACE_TOOL_NAMES } from '../../lib/traceToolNames.js';
import { Agent } from '../Agent.js';
import { buildSkillGraphDeclared } from './skillGraphDeclared.js';
import { FINDINGS_INSTRUCTION_ID } from './findings/reserved.js';
import { readInputsLayerOption } from './honesty/armed.js';
import { readTimeOptions } from '../../time/clock.js';
import { ONTOLOGY_INSTRUCTION, ONTOLOGY_INSTRUCTION_ID } from '../../ontology/instruction.js';
import { resolveAct } from './act.js';
import { resolveCompactionOptions } from './window/options.js';
import { summarizeOldest } from './window/strategies/summarizeOldest.js';
import { assertAgentRecipe } from '../../recipes/defineAgentRecipe.js';
import { resolveScriptTravel } from './scripts/attachment.js';
import { scriptOf } from '../../tools/script.js';
import { asyncConfigureRefusal, duplicateRecipeRefusal, duplicateRegistrationRefusal, recursiveRecipeRefusal, resolveRecipeConflictPolicy, } from '../../recipes/apply.js';
import { LOCAL_SOURCE } from '../../recipes/provenance.js';
/**
* Fluent builder. `tool()` accepts any Tool<TArgs, TResult> and registers
* it by its schema.name. Duplicate names throw at build time.
*/
export class AgentBuilder {
opts;
systemPromptValue = '';
/** Whether `.system()` has been called. Separate from the VALUE, because
* `.system('')` is a legitimate call (an agent with no instructions) and
* `''` is also the default — only a flag can tell the two apart, and the
* refusal has to fire on the second call regardless of what was passed. */
systemPromptSet = false;
/**
* Cache policy for the base system prompt. Set via the optional
* 2nd argument to `.system(text, { cache })`. Default `'always'` —
* the base prompt is stable per-turn and an ideal cache anchor.
*/
systemPromptCachePolicy = 'always';
systemPromptCacheExplicit = false;
/**
* Global cache kill switch. Set via `Agent.create({ caching: 'off' })`
* (handled in `AgentOptions` propagation). Defaults to `false`
* (caching enabled). When `true`, the CacheGate decider routes to
* `'no-markers'` every iteration regardless of other rules.
*/
cachingDisabledValue = false;
/**
* Optional explicit CacheStrategy override. Default: undefined, which
* means the agent chooses from the provider's declared `promptCaching`
* (`cacheStrategyFor`) at construction. Power users override here for
* custom backends or test mocks.
*/
cacheStrategyOverride;
registry = [];
injectionList = [];
/** Captured from `.skillGraph(graph)` — the cursor resolver the Injection
* Engine uses to `from`-gate route triggers. Undefined unless a graph with
* route edges was mounted. */
skillGraphNextSkill;
/** Captured from `.skillGraph(graph)` — the reachable-set resolver the
* read_skill gate uses to reject out-of-set skill jumps. Undefined → the gate
* is off (plain read_skill agents are unaffected). */
skillGraphReachable;
/** Captured from `.skillGraph(graph)` — the relevance entry scorer
* (`graph.scoreEntries`), present only with `.entryByRelevance()`. When set, the
* PickEntry stage picks the starting skill by relevance once per turn. */
skillGraphScoreEntries;
/** Captured from `.skillGraph(graph)` — the `to` end of every declared edge, i.e.
* which skills the graph WIRES. Read only by the read_skill gate's open-skill
* rule (8.4.0). */
skillGraphEdgeTargets;
/** Captured from `.skillGraph(graph)` — the cursor resolver that also reports the
* clause that won (`graph.explainNextSkill`, 8.5.0). Optional: a graph built
* before it existed still routes, it just cannot narrate the hop. */
skillGraphExplainNextSkill;
/** Captured from `.skillGraph(graph)` — the suppression reporter
* (`graph.supersededEntries`, 8.15.0). Optional: a graph built before it existed
* routes identically, it just cannot name the entries the cursor kept off the
* wire. */
skillGraphSupersededEntries;
/** Is the mounted graph a decision `tree()`? DERIVED from `graph.nodes` — a tree
* is the only shape that draws `predicate` diamonds — so no new field had to be
* added to the public `SkillGraph`. Feeds the gate's tree-specific refusal. */
skillGraphIsTree = false;
/** Captured from `.skillGraph(graph, options)` (SG-C) — the graph's
* turn-routing plan plus the mount's posture/continuity and the node-id
* set droppedResume checks against. Undefined for every graph without the
* new options → the Agent wires nothing new. */
skillGraphCascade;
/** Captured from `.skillGraph(graph)` — the graph's note that it DEFERRED its
* body-contract checks (built without `knownTools`, it could not tell a typo
* from a baseline tool this agent registers), plus the compiled skills to run
* them over. `build()` runs them exactly once, against the full tool registry.
* This capture is the FALLBACK for a structurally-typed graph whose skills do
* not carry the per-skill note: the primary collection reads
* `SKILL_GRAPH_DEFERRED_CONTRACT_KEY` off each injection's metadata, which is
* how a graph fed through `.skills({ list: () => graph.skills })` — a door
* that never sees the graph object — still gets its deferred checks run.
* Skills found by both are deduped by id: nothing re-runs, nothing
* double-reports. Undefined when the graph already ran them (`knownTools`
* given), switched them off (`check: 'off'`), or predates the note. */
skillGraphDeferredBodyContract;
/** Captured from `.skillGraph(graph, options)` (9.19.0) — the three brain
* fields, verbatim; folded + validated at `build()` where the FINAL
* injection list (the other declaration home) exists. */
skillGraphBrainOptions;
/** Captured from `.skillGraph(graph)` (9.19.0) — the graph's node-id set,
* captured UNCONDITIONALLY (unlike the cascade's copy, which only exists
* when cascade options were asked for): the brains check-up needs it on
* a bare mount too. Undefined when the graph object carries no `nodes`. */
skillGraphNodeIds;
/** Captured from `.skillGraph(graph)` (9.50.0) — the DECLARED map (nodes +
* edges, verbatim), projected once at mount so every run can file it as
* `agentfootprint.skill.graph_declared`. Undefined when the graph cannot
* state one (a structurally-typed graph without `nodes`) — the event then
* never fires, and the recording honestly carries no declared map. */
skillGraphDeclared;
/** `.maps()` (9.58.0) — the mount kernel's options; the plan itself is
* resolved at build(), where the final injection list and the mounted
* graph both exist. Absent = no kernel, zero delta. */
mapsOptions;
/** `.claims()` (9.61.0) — the declared claim contract; resolved at build(). */
claimsContract;
memoryList = [];
/**
* Optional terminal contract — see `outputSchema()`. Stored on the
* builder, propagated to the Agent at `.build()` time.
*/
outputSchemaParser;
answerValidationConfig;
negativeClaimsConfig;
/** Corrective re-asks the loop may spend. `0` (the default) means the
* schema is judged once, at the caller's boundary, exactly as it always
* was — and no enforcement is mounted in the chart at all. */
/** The evidence gate (9.35.0) — set by `.namesAndNumbersFromEvidence()`,
* resolved at the call site so a bad posture throws where it was typed.
* Undefined for every agent that did not ask for it, which is what makes
* the feature byte-identical when unused. */
evidenceGate;
/** The tool posture (9.36.0) — set by `.toolsFromActiveSkill()`. False for
* every agent that did not ask for it, and the stamp it gates is the ONLY
* thing it does, which is what makes the feature byte-identical when
* unused. */
toolsFromActiveSkillValue = false;
/** `.limitsTravelWithTheAnswer()` (this release). False for every agent
* that did not ask for it, and the ONE thing it gates is which stage
* function the final branch mounts — which is what makes the feature
* byte-identical when unused. The recording half is unconditional. */
limitsTravelValue = false;
/** `.scriptTravelsWithTheAnswer()` — resolved at the call site; undefined
* for every agent that did not ask, which keeps it byte-identical. */
scriptTravelValue;
/** `.inputsLayer()` (honesty layer 2). False for every agent that did not
* ask for it; a REGISTERED ruled tool arms the layer on its own, so this
* is for ruled tools a ToolProvider serves — and for declared sources
* without the findings ledger (`inputsLayerSourcesValue`). */
inputsLayerValue = false;
/** `.inputsLayer({ argumentSources: true })` (honesty layer 2): declared
* sources without the findings ledger. False unless asked for. */
inputsLayerSourcesValue = false;
/** `.resultsLayer()` (honesty layer 3, step 7b). False for every agent that
* did not ask for it; a REGISTERED tool with a `ToolPeriod` arms the layer
* on its own, so this is for provider-served tools and for tools that
* declare a period only on their results. */
resultsLayerValue = false;
/** `.answerLayer()` (honesty layer 4). Undefined for every agent that did
* not ask for it — the chart, the rows, the events and the answer are then
* byte-identical; `standingLine` is the prose line's own opt-in. */
answerLayerValue;
/** `.time()` (the time layer). Undefined for every agent that did not ask —
* then no clock is read, no time row is filed and `time` on a run is refused. */
timeValue;
outputSchemaRetries = 0;
outputSchemaStrategy = 'auto';
outputSchemaJson;
/** 3-tier output fallback chain — set via `.outputFallback({...})`.
* Optional; absent = current throw-on-validation-failure behavior. */
outputFallbackCfg;
/**
* Optional `ToolProvider` set via `.toolProvider()`. Propagated to
* the Agent's Tools slot subflow + tool-call dispatcher; consulted
* per iteration so dynamic chains (`gatedTools`, `skillScopedTools`)
* react to current activation state.
*/
toolProviderRef;
/**
* Optional override for `AgentOptions.maxIterations`. When set via
* the `.maxIterations()` builder method, takes precedence over the
* value passed to `Agent.create({ maxIterations })`.
*/
maxIterationsOverride;
/**
* Observers collected via `.watch()`. Attached to the built Agent before `build()` returns
* (each via `agent.attach(rec)`), in call order.
*/
recorderList = [];
// Voice config — defaults until the consumer calls .appName() /
// .commentaryTemplates() / .thinkingTemplates(). Stored as plain
// dicts (Record<string, string>) so the builder doesn't depend on
// the template-engine modules at compile time; the runtime types
// come from the agentfootprint barrel exports.
appNameValue = 'Chatbot';
commentaryOverrides = {};
thinkingOverrides = {};
/**
* Optional rules-based reliability config (v2.11.5+). Set via
* `.reliability({...})`. Wraps every `CallLLM` execution in a
* retry/fallback/fail-fast loop driven by `preCheck` and `postDecide`
* rules. See `ReliabilityConfig` for the rule shape.
*/
reliabilityConfig;
/**
* Optional ThinkingHandler (v2.14+). Three states:
* - undefined (default): the provider's declared handler
* (`LLMProvider.thinkingHandler`)
* - explicit handler: override it
* - explicit `null`: opt out (no thinking handler mounted at all)
*
* The framework wraps the configured handler in a real footprintjs
* sub-subflow at chart build time (see `buildThinkingSubflow`).
* Mounted as a stage AFTER CallLLM inside `sf-call-llm`. Build-time
* conditional — no stage when no handler resolves.
*/
thinkingHandlerValue;
/**
* v2.14+ — request-side thinking activation. When set, every LLM
* call carries `LLMRequest.thinking = { budget }`, asking the
* provider (Anthropic) to emit reasoning blocks. Independent from
* `.thinkingHandler()` (response-side normalization choice).
*/
thinkingBudgetValue;
selfExplainConfig;
checkInConfig;
/** Per-run config resolver set via `.configure()`. Undefined = the agent
* runs on its build-time model + system prompt, unchanged. */
runConfigFn;
/** The agent's one window strategy, from `.window()` or `.compaction()`.
* Undefined = no window stage exists, the ReAct loop target is unchanged,
* and the run is byte-identical to an agent that never heard of them. */
windowStrategyValue;
/** WHICH door set it (8.18.0). Recorded so every "already set" refusal can
* name the call the caller has to go and look at, in every direction. */
windowStrategyDoor;
/** The tool-dispatch chain, in call order. Empty = no chain, no ledger. */
toolMiddlewareList = [];
/** The message chain, in call order. Empty = no chain, no ledger. */
messageMiddlewareList = [];
/** `.act()` is the posture block: one per agent. See the method. */
actCalled = false;
/** The recipes applied, in DECLARATION ORDER — the order `.recipe()` was
* called, which is the order their builder calls ran. Reported verbatim on
* the run manifest. Empty for every agent that never called `.recipe()`,
* and an empty list puts NO field on the manifest. */
appliedRecipeList = [];
/** The recipe application stack while a `configure` is running — OUTERMOST
* first, because a recipe may apply another recipe and the innermost one is
* the code that literally called `.tool()`. Empty means a direct call. */
recipeStack = [];
/** Which source registered each tool NAME, and each injection ID. Two maps
* keyed by the raw name, never one map keyed by a composed string: joining a
* kind and a name is the separator-donation collision this repo has fixed
* seven times, and there is nothing to gain by risking it here. Consulted
* only when a duplicate is detected, so an agent with no recipes pays two
* `Map.set` calls and nothing else. */
toolSources = new Map();
injectionSources = new Map();
/** The findings ledger's options (9.101.0), set by `.findings()` — the ONE
* door — and undefined on every agent that never called it. Composed into
* the options `build()` hands `Agent` as `AgentOptions.findings`. */
findingsValue;
/** Tool choice by classifier (9.105.0), set by `.toolChoice()` — the ONE
* door — and undefined on every agent that never called it. Composed into
* the options `build()` hands `Agent` as `AgentOptions.toolChoice`. */
toolChoiceValue;
/** The declared ontology (9.106.0), set by `.ontology()` — the ONE door —
* and undefined on every agent that never called it. Composed into the
* options `build()` hands `Agent` as `AgentOptions.ontology`. */
ontologyValue;
constructor(opts) {
this.opts = opts;
// Cache layer: opts.caching === 'off' propagates to scope's
// `cachingDisabled` kill switch read by CacheGate. opts.cacheStrategy
// overrides the registry-resolved default.
if (opts.caching === 'off')
this.cachingDisabledValue = true;
if (opts.cacheStrategy !== undefined)
this.cacheStrategyOverride = opts.cacheStrategy;
// `findings` given as an option goes through the one door (9.101.0), so
// the instruction piece rides with the arm however it was spelled — an
// armed agent with no ask would decorate schemas the model was never told
// about. A later `.findings()` call is then the second call, and refused.
if (opts.findings !== undefined)
this.findings(opts.findings);
// Same door for `toolChoice` (9.105.0): the option form is validated by
// the one method, so a misconfigured classifier fails here, not per call.
if (opts.toolChoice !== undefined)
this.toolChoice(opts.toolChoice);
// Same door for `ontology` (9.106.0): the option form registers the
// always-on ask through the one method, so a map the model was never
// told how to read cannot be mounted.
if (opts.ontology !== undefined) {
this.ontology(opts.ontology, opts.ontologyAsk === undefined ? undefined : { ask: opts.ontologyAsk });
}
else if (opts.ontologyAsk !== undefined) {
throw new Error('Agent.create: `ontologyAsk` without `ontology` — the ask is what the model is told to do ' +
'with a declared map, so it needs the map. Pass `ontology` (defineOntology) beside it.');
}
}
/**
* Set the base system prompt.
*
* @param prompt - The system prompt text. Stable per-turn.
* @param options - Optional config. `cache` controls how the
* CacheDecision subflow treats this prompt block:
* - `'always'` (default) — cache the base prompt as a stable
* prefix anchor. Highest cache-hit rate; recommended for
* production agents whose system prompt rarely changes.
* - `'never'` — skip caching. Use if the prompt contains volatile
* content (timestamps, per-request user IDs).
* - `'while-active'` — semantically equivalent to `'always'` for
* the base prompt (it's always active by definition).
* - `{ until }` — conditional invalidation (e.g., flush after iter 5).
*/
system(prompt, options) {
if (this.systemPromptSet) {
throw new Error('AgentBuilder.system: already set. Each agent has one base system prompt — a second ' +
'call used to REPLACE the first silently, so the instructions you wrote first were ' +
'never sent and nothing said so. For a second block of always-on system content, ' +
'add it as an injection: .steering(defineSteering({ id, content })) composes into ' +
'the same slot, is visible in the trace as its own entry, and can be cached ' +
'separately. To decide the prompt PER RUN, use .configure(({ defaults }) => ' +
'({ instructions: … })). To build one string from parts, join them yourself and ' +
'pass the result once.');
}
this.systemPromptSet = true;
this.systemPromptValue = prompt;
if (options?.cache !== undefined) {
this.systemPromptCachePolicy = options.cache;
this.systemPromptCacheExplicit = true;
}
return this;
}
tool(tool) {
const name = tool.schema.name;
if (this.registry.some((e) => e.name === name)) {
throw new Error(this.duplicateRefusal('tool name', name, this.toolSources, 'Agent.tool()'));
}
this.registry.push({ name, tool: tool });
this.toolSources.set(name, this.currentSource());
return this;
}
/**
* Who is registering right now: the app itself, or the recipe whose
* `configure` is on the stack. A fresh object per call so a later push onto
* the stack cannot rewrite what an earlier registration recorded.
*/
currentSource() {
return this.recipeStack.length === 0
? LOCAL_SOURCE
: { kind: 'recipe', stack: [...this.recipeStack] };
}
/**
* The sentence a duplicate registration gets.
*
* Two sources both being LOCAL keeps the message it has always had, to the
* byte: an app whose tests read that string should not have them break
* because a feature it does not use shipped. As soon as either side is a
* recipe the message names both — which is the whole point, since "I never
* registered a `search` tool" is true and unhelpful when a composition did.
*/
duplicateRefusal(what, name, sources, callSite) {
const existing = sources.get(name);
const incoming = this.currentSource();
if (existing?.kind === 'local' && incoming.kind === 'local') {
return what === 'tool name'
? `Agent.tool(): duplicate tool name '${name}'`
: `Agent.injection(): duplicate id '${name}'`;
}
return duplicateRegistrationRefusal({ what, name, existing, incoming, callSite });
}
/**
* Apply a **recipe** — a named, versioned composition over the builder
* methods below (9.48.0).
*
* Every capability an agent needs already ships; what did not was a declared,
* versioned, inspectable unit of CONFIGURATION. So an agent's setup lived as
* prose in an example, was copy-pasted into an app, drifted there, and
* afterwards nothing on the run could say which composition produced the
* agent that answered. A recipe is that missing noun, and each applied one
* puts an `{ id, version }` row on the run manifest.
*
* `configure` runs SYNCHRONOUSLY and immediately — at the position in the
* chain where you wrote `.recipe()`, so declaration order is application
* order and a later call still wins the way it always has. There is no
* deferred phase, nothing to close and nothing registered anywhere: see
* {@link AgentRecipe} for why that limit is deliberate.
*
* **Conflicts.** A tool name or injection id a recipe introduces that is
* already taken refuses right here, naming BOTH sources — which recipe, or
* the app itself. `'error'` is the only policy (`{ conflict }`); anything
* else is refused by name rather than approximated.
*
* @example an app composing two published recipes
* ```ts
* import { defineAgentRecipe } from 'agentfootprint/recipes';
*
* const agent = Agent.create({ provider, model })
* .recipe(supportDesk) // system prompt + order lookup
* .recipe(housePolicy) // the steering every agent here carries
* .tool(escalate) // and one tool this app adds itself
* .build();
* ```
*/
recipe(recipe, options) {
assertAgentRecipe(recipe, 'AgentBuilder.recipe');
// Resolved before anything is applied: a policy this library does not have
// must not half-apply a composition before it says so.
resolveRecipeConflictPolicy(options?.conflict, 'AgentBuilder.recipe');
const applied = { id: recipe.id, version: recipe.version };
// TWO different facts, two different refusals. On the STACK means the
// composition is its own ancestor and `configure` would recurse forever; on
// the LIST means the chain simply names one composition twice. Telling an
// author their recursion is a duplicate sends them to the wrong line.
const applying = this.recipeStack.find((r) => r.id === applied.id);
if (applying) {
throw new Error(recursiveRecipeRefusal({
stack: [...this.recipeStack],
incoming: applied,
callSite: 'AgentBuilder.recipe',
}));
}
const already = this.appliedRecipeList.find((r) => r.id === applied.id);
if (already) {
throw new Error(duplicateRecipeRefusal({
existing: already,
incoming: applied,
callSite: 'AgentBuilder.recipe',
}));
}
this.recipeStack.push(applied);
try {
const returned = recipe.configure(this);
// `build()` is synchronous, so nothing would ever await this. Refused by
// name rather than left to land on an agent that is already built.
if (typeof returned?.then === 'function') {
throw new Error(asyncConfigureRefusal(applied, 'AgentBuilder.recipe'));
}
}
finally {
// `finally`, not the happy path: a refusal raised inside `configure`
// (a duplicate tool, say) must not leave the stack claiming a recipe is
// still applying — the next registration would be attributed to it.
this.recipeStack.pop();
}
// The manifest row is recorded only once `configure` has run CLEANLY. A
// recipe whose application was refused half-way has not produced this
// agent, and a row claiming it did would be the manifest asserting
// something that did not happen. (Recursion is caught by the stack above,
// so nothing depends on the row being written early.)
this.appliedRecipeList.push(applied);
return this;
}
/**
* Register many tools at once. Convenience for tool sources that
* return a list (e.g., `await mcpClient(...).tools()`). Each tool
* is registered via `.tool()` so duplicate-name validation still
* fires per-entry.
*/
tools(tools) {
for (const t of tools)
this.tool(t);
return this;
}
/**
* Wire a chainable `ToolProvider` (from `agentfootprint/providers`)
* as the agent's per-iteration tool source.
*
* The provider is consulted EVERY iteration via `provider.list(ctx)`
* with `ctx = { iteration, activeSkillId, identity }`. Tools the
* provider emits flow into the Tools slot alongside any static
* tools registered via `.tool()` / `.tools()`. The tool-call
* dispatcher also consults the provider so dynamic chains
* (`gatedTools`, `skillScopedTools`) dispatch correctly when their
* visible-set changes mid-turn.
*
* Throws if called more than once on the same builder (avoids
* silent override surprises).
*
* @example Permission-gated baseline
* import { gatedTools, staticTools } from 'agentfootprint/providers';
* import { PermissionPolicy } from 'agentfootprint/security';
*
* const policy = PermissionPolicy.fromRoles({
* readonly: ['lookup', 'list_skills', 'read_skill'],
* admin: ['lookup', 'list_skills', 'read_skill', 'delete'],
* }, 'readonly');
*
* const provider = gatedTools(
* staticTools(allTools),
* (toolName) => policy.isAllowed(toolName),
* );
*
* const agent = Agent.create({ provider: llm, model })
* .system('You answer.')
* .toolProvider(provider)
* .build();
*/
toolProvider(provider) {
if (this.toolProviderRef) {
throw new Error('AgentBuilder.toolProvider: already set. Each agent has at most one external ToolProvider.');
}
this.toolProviderRef = provider;
return this;
}
/**
* Decide this run's model and/or system prompt when the run starts.
*
* An agent is built once and run many times, but not every run wants the
* same model or the same instructions: a long message may deserve the
* bigger model, a tenant may have its own house rules, a canary may want
* last week's prompt. Rebuilding the whole agent per request works and is
* wasteful; reaching in and mutating one is worse, because the trace then
* describes an agent that no longer exists.
*
* The resolver runs ONCE per `run()`, at the start of the run, and what it
* returns is **committed to the trace** — `resolvedModel` and
* `resolvedInstructions` land in the run's commit log before the first LLM
* call, and the LLM call reads them from there. So the recording says which
* model actually answered instead of which model the agent was built with.
*
* Return `{}` (or nothing) to keep the defaults; `ctx.defaults` carries
* them, so a resolver can decide relative to what was built rather than
* restating it. Omit `.configure()` entirely and every run behaves — and
* records — exactly as it did before.
*
* This is the RUN axis only. Tools are the iteration axis and already have
* an owner: `.toolProvider()`, consulted every iteration.
*
* Throws if called more than once (same rule as `.toolProvider()` — a
* silently-overridden resolver is a config that lies).
*
* @example Bigger model for a bigger question
* const agent = Agent.create({ provider, model: 'small-model' })
* .system('You answer support questions.')
* .configure(({ message, defaults }) =>
* message.length > 500 ? { model: 'big-model' } : {},
* )
* .build();
*
* @example Per-tenant house rules
* const agent = Agent.create({ provider, model })
* .system('You answer support questions.')
* .configure(({ identity, defaults }) => ({
* instructions: `${defaults.instructions}\n\n${rulesFor(identity?.tenant)}`,
* }))
* .build();
*/
configure(fn) {
if (this.runConfigFn) {
throw new Error('AgentBuilder.configure: already set. One resolver per agent — a second one would ' +
'silently override the first.');
}
if (typeof fn !== 'function') {
throw new Error(`AgentBuilder.configure: expected a function (ctx) => ({ model?, instructions? }), got ` +
`${typeof fn}.`);
}
this.runConfigFn = fn;
return this;
}
/**
* Everything this agent DOES about its own loop, in one block.
*
* Tools do the work. `.act()` decides about the work. `watch` remembers
* both — and nothing can act without being watched.
*
* Five keys, one per moment of a turn, each optional and each the exact
* argument the individual door takes:
*
* ```ts
* const agent = Agent.create({ provider, model })
* .act({
* input: [scrubSSNs], // the message, before the run commits it
* beforeTool: [refundCeiling], // every call, before it is dispatched
* afterTool: [hideRawPII], // every result, before the model reads it
* window: slidingWindow({ keepRecentTurns: 12 }),
* output: [noInternalCodenames],
* })
* .build();
* ```
*
* **It is sugar, and provably so.** Each key is forwarded to the door that
* already owned it — `.messageMiddleware()`, `.toolMiddleware()`,
* `.window()` — so the agent it builds sends the same request bytes and
* files the same records as the same rules spelled out one call at a time.
* That equivalence is pinned per key by tests, the way `.compaction()`'s is.
*
* **The keys cannot fall behind the loop.** They are locked at compile time
* against `LoopMoment`, so a sixth moment cannot ship without a key here.
*
* **A rule speaks where its hooks say, not where you filed it.** `beforeTool`
* and `afterTool` are one chain; an entry with both `onToolCall` and
* `onToolResult` runs at both moments whichever key you wrote it under —
* the KEYS are named for the moments, the HOOKS for what they receive — and
* an entry named
* under both keys is the same object attached once. A governance rule that
* silently did not run because it was written in the wrong bucket is exactly
* the failure this library exists to make impossible — so the bucket is
* checked for the hook it names, and the hooks decide the rest.
*
* **Call it once.** A second `.act()` throws: two posture blocks means the
* answer to "what does this agent do at each moment?" is in two places, and
* the second one silently wins. Adding one piece to an agent somebody else
* built — a plugin, a policy pack — is what the individual doors are for,
* and they stay open for exactly that.
*
* @param options - One key per moment. `input` / `output` take message
* middleware, `beforeTool` / `afterTool` take tool middleware, `window`
* takes a `WindowStrategy`. Unknown keys throw.
*/
act(options) {
if (this.actCalled) {
throw new Error('AgentBuilder.act: already called. One posture block per agent — a second would put ' +
'the answer to "what does this agent do at each moment of its loop?" in two places, ' +
'with the later one silently winning. To ADD one rule to an agent that already has ' +
'a posture, use the individual door for that moment (.toolMiddleware(), ' +
'.messageMiddleware(), .window()); they compose incrementally by design.');
}
// Resolved BEFORE anything is attached: a bundle with a bad key must
// leave the builder exactly as it found it, not half-configured. Every
// entry is validated inside `resolveAct`, and the window — the one key
// whose validation lives on its own door — goes first for the same
// reason.
const resolved = resolveAct(options);
if (resolved.window !== undefined) {
this.assertNoWindowStrategy('act');
// Noted BEFORE delegating: `.window()` is the setter both doors share,
// and the refusal a later call meets should name the door this caller
// actually wrote.
this.noteWindowStrategyDoor('.act({ window })');
this.window(resolved.window);
}
this.actCalled = true;
if (resolved.message.length > 0)
this.messageMiddleware(...resolved.message);
if (resolved.tool.length > 0)
this.toolMiddleware(...resolved.tool);
return this;
}
/**
* Choose how the live context window is kept inside its budget.
*
* This is the general door; the strategy decides everything about WHEN it
* acts and WHAT leaves. Three ship, and they share one turn segmentation
* and one refusal engine, so a refusal reason means the same thing under
* all of them:
*
* `summarizeOldest({ thresholdTokens, summarizer, ... })`
* fold the oldest span into one summary message. `.compaction()` is
* this, spelled shorter.
* `slidingWindow({ keepRecentTurns })`
* keep the last N turns and drop older ones. No summarizer, no LLM
* call, no usage requirement — it runs on any provider.
* `tokenBudget({ thresholdTokens })`
* the counted-token trigger, dropping instead of summarizing.
*
* Never removed by any of them: the system envelope, the recent turns, and
* any turn holding something unresolved — an unanswered tool call, a paused
* tool, a pending check-in. Those refuse BY NAME in the record and the
* strategy takes the next oldest instead. Removing an unanswered question
* would destroy the referent of the answer that has not arrived yet, and
* splitting a `tool_use` from its `tool_result` produces a request the
* vendor rejects.
*
* **Whatever leaves the window stays in the ledger.** footprintjs's commit
* log is append-only, so the turns were committed before the strategy ran
* and remain byte-identical; every strategy files its own recorded step
* naming the `runtimeStageId`s whose messages left, and emits one
* `context.evicted` per message. Removing is not forgetting.
*
* Exactly one strategy per agent. Omit this (and `.compaction()`) and
* nothing changes: no stage, no extra committed key, the same request bytes.
*
* @example
* ```ts
* import { Agent, slidingWindow } from 'agentfootprint';
*
* const agent = Agent.create({ provider, model })
* .window(slidingWindow({ keepRecentTurns: 12 }))
* .build();
* ```
*/
window(strategy) {
this.assertNoWindowStrategy('window');
if (strategy === null ||
typeof strategy !== 'object' ||
typeof strategy.plan !== 'function' ||
typeof strategy.name !== 'string' ||
strategy.name.length === 0) {
throw new Error(`AgentBuilder.window: expected a WindowStrategy — an object with a non-empty \`name\` ` +
`and a \`plan(input)\` method — got ${typeof strategy}. The shipped ones are ` +
`summarizeOldest({ ... }), slidingWindow({ ... }) and tokenBudget({ ... }); call the ` +
`factory, do not pass it.`);
}
this.assertSummarizerIsNotTheAgentItself(strategy.billing, 'AgentBuilder.window');
this.windowStrategyValue = strategy;
// `.act({ window })` delegates here, and its own door was noted first —
// do not overwrite a more specific attribution with this generic one.
this.noteWindowStrategyDoor(this.windowStrategyDoor ?? '.window()');
return this;
}
/**
* Refuse a strategy that would bill the agent's OWN provider instance for
* the agent's OWN model (8.14.0).
*
* Not about money — `model` is required now, so nothing is billed quietly.
* It is about two calls that are configured identically and provably behave
* differently: the agent's call goes through `reliability`, any provider
* decorator and the cache subflow; the summarizer's call goes through none
* of them (see `runSummarizer`). Same object, same model, two behaviours,
* and nobody typed the difference.
*
* Deliberately narrow. A different INSTANCE of the same vendor with the same
* model is allowed — "use the strong model to write the summary, because a
* bad summary poisons every turn after it" is a real choice — and a second
* instance also ends the shared per-instance state (cursors, rate-limit
* buckets, keep-alive pools) that made this pairing bite in the first place.
*
* Checked at every door that can set one. `.compaction({...})` and
* `.window(summarizeOldest({...}))` are the same policy, and since 9.14.0
* `.memory(defineMemory({ strategy: { kind: 'summarize', llm, model } }))`
* is a third — a memory that folds recall makes the same un-decorated call
* against the same pairing. A rule that only some doors enforce is advice.
*/
assertSummarizerIsNotTheAgentItself(billing, label) {
if (billing === undefined)
return;
if (billing.provider !== this.opts.provider)
return;
if (billing.model !== this.opts.model)
return;
throw new Error(`${label}: the summarizer is the agent's own provider INSTANCE and the agent's own model ` +
`('${billing.model}'). Those two calls now look identical and are not: the agent's call ` +
`runs through reliability retries, any withRetry/withFallback/withCircuitBreaker ` +
`decorator and the cache; the summarizer's call runs through none of them — one ` +
`attempt, no fallback, no cache. A difference nobody typed is the kind this library ` +
`refuses.\n` +
`Fix, one word: give the summarizer its OWN instance — \`anthropic()\` written again ` +
`(as \`summarizer\` on a window strategy, as \`llm\` on a summarize memory) rather than ` +
`the variable you passed to Agent.create. That also stops the two roles sharing ` +
`per-instance state. Or name a different (usually cheaper) model.`);
}
/**
* Keep the live context window inside a token budget — without ever losing
* the record.
*
* Sugar for `.window(summarizeOldest(options))`, and byte-for-byte the same
* agent. It keeps its own name because compaction is what the market calls
* this and it is the strategy most people want first.
*
* At each ReAct iteration boundary, compaction compares the LAST call's
* **adapter-reported** input tokens against `thresholdTokens`. Over budget,
* it folds the oldest foldable span of the conversation into one summary
* message and sends that instead. Counted, never guessed: a provider that
* reports no usage gets a named refusal
* (`CompactionUnmeasurableError`) rather than an invented number.
*
* **The fold edits the window, not the record.** The turns it folds stay in
* the run's commit log byte-identical — footprintjs's log is append-only,
* so a fold cannot erase them even in principle. The summary enters as its
* own recorded step naming every `runtimeStageId` it folded, plus what was
* measured and what refused to fold. A compacted run is still a provable
* run: the lens draws a fold seam, not a hole.
*
* Never folded: the system envelope, the last `keepRecentTurns` turns, and
* any turn holding something unresolved — an unanswered tool call, a paused
* tool, a pending check-in. Folding an unanswered question would destroy
* the referent of the answer that has not arrived yet, so those refuse by
* name and the fold takes the next oldest instead.
*
* Omit `.compaction()` and nothing changes: no stage, no extra keys, the
* same request bytes as before.
*
* @example
* ```ts
* const agent = Agent.create({ provider: anthropic(), model: 'claude-sonnet-4-5' })
* .compaction({
* thresholdTokens: 120_000,
* summarizer: anthropic(),
* model: 'claude-haiku-4-5', // the cheap one writes the summary
* })
* .build();
* ```
*/
compaction(options) {
this.assertNoWindowStrategy('compaction');
// Validated here so the error names the door the caller actually used;
// `summarizeOldest` re-runs the SAME validator under its own label, so the
// two doors can never drift into accepting different things.
const resolved = resolveCompactionOptions(options, 'AgentBuilder.compaction');
this.assertSummarizerIsNotTheAgentItself({ provider: resolved.summarizer, model: resolved.model }, 'AgentBuilder.compaction');
this.windowStrategyValue = summarizeOldest(options);
this.noteWindowStrategyDoor('.compaction()');
return this;
}
/**
* One window strategy per agent, whichever door set it — a second would
* silently override the first, and a window policy that quietly changed is
* a policy you cannot audit.
*
* **Every refusal names the door that set it (8.18.0).** The three doors are
* one setting, so a caller who hits this is holding two lines of code and
* needs to know which one already won. Before, the direction decided how much
* you were told: `.window()` named the strategy and then talked about
* `.compaction()` — even when `.act({ window })` was what had set it — while
* `.act()` said "set by .window() or .compaction()", an `or` that was
* sometimes neither. `windowStrategyDoor` records the fact once, at the
* moment it becomes true, and all three sentences read it.
*/
assertNoWindowStrategy(door) {
const existing = this.windowStrategyValue;
if (existing === undefined)
return;
const setBy = this.windowStrategyDoor ?? '.window()';
const law = 'One window strategy per agent, whichever door set it — a second would silently ' +
'override the first, and a window policy that quietly changed is a policy you cannot ' +
'audit.';
if (door === 'act') {
throw new Error(`AgentBuilder.act: this agent already has a window strategy ('${existing.name}'), set ` +
`by ${setBy}. ${law} Drop the \`window\` key here, or remove the ${setBy} call.`);
}
if (door === 'compaction') {
throw new Error(`AgentBuilder.compaction: this agent already has a window strategy ` +
`('${existing.name}'), set by ${setBy}. ${law} \`.compaction()\`, \`.window()\` and ` +
'`.act({ window })` are three doors into the same setting.');
}
throw new Error(`AgentBuilder.window: this agent already has a window strategy ('${existing.name}'), set ` +
`by ${setBy}. ${law} \`.compaction()\` is this same door with summarizeOldest already ` +
'in it, and `.act({ window })` is it again inside the posture block.');
}
/** Record which door set the window strategy, for the refusal above. */
noteWindowStrategyDoor(door) {
this.windowStrategyDoor = door;
}
/**
* Override the ReAct iteration cap set via `Agent.create({
* maxIterations })`. Convenience for builder-style code that prefers
* fluent setters over constructor opts. Last call wins.
*
* Throws if `n` is not a positive integer or exceeds the hard cap
* (`clampIterations`'s upper bound).
*/
maxIterations(n) {
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`AgentBuilder.maxIterations: expected a positive integer, got ${n}.`);
}
this.maxIterationsOverride = n;
return this;
}
/**
* Watch this agent. `.act()` says what the agent may do; `.watch()` says
* who is looking while it does it.
*
* Every observer handed here is attached before `build()` returns, so it
* sees every event from the very first run — there is no window where the
* agent has run and nobody was watching.
*
* Variadic, because observers come in sets:
*
* ```ts
* const agent = Agent.create({ provider, model })
* .watch(toolChoiceRecorder(), routeRecorder())
* .act({ beforeTool: [budgetGuard] })
* .build();
* ```
*
* Build time, not run time. This returns the builder; `agent.attach(o)`
* attaches to a live agent and returns an `Unsubscribe` you own. Same
* mechanism underneath — `.watch()` replays through `agent.attach()` at
* the end of `build()` — so mixing the two is fine and order is preserved.
*
* Called more than once, the sets concatenate in call order. Nothing is
* de-duplicated here; footprintjs's executor dedupes by recorder id at run
* time, so the same observer handed in twice still fires once.
*/
watch(...observers) {
for (const observer of observers)
this.recorderList.push(observer);
return this;
}
/**
* Set the agent's display name — substituted as `{{appName}}` in
* commentary + thinking templates. Same place to brand a tenant
* ("Acme Bot"), distinguish multi-agent roles ("Triage" vs
* "Reviewer"), or localize ("Asistente"). Default: `'Chatbot'`.
*/
appName(name) {
this.appNameValue = name;
return this;
}
/**
* Override agentfootprint's bundled commentary templates. Spread on
* top of `defaultCommentaryTemplates`; missing keys fall back. Same
* `Record<string, string>` shape with `{{vars}}` substitution as
* the bundled defaults — see `defaultCommentaryTemplates` for the
* full key list.
*
* Use cases: i18n (`'agent.turn_start': 'El usuario...'`), brand
* voice ("You: {{userPrompt}}"), per-tenant customization.
*/
commentaryTemplates(templates) {
this.commentaryOverrides = { ...this.commentaryOverrides, ...templates };
return this;
}
/**
* Override agentfootprint's bundled thinking templates. Same
* contract shape as commentary; different vocabulary — first-person
* status the chat bubble shows mid-call. Per-tool overrides go via
* `tool.<toolName>` keys (e.g., `'tool.weather': 'Looking up the
* weather…'`). See `defaultStatusTemplates` for the full key list.
*/
thinkingTemplates(templates) {
this.thinkingOverrides = { ...this.thinkingOverrides, ...templates };
return this;
}
// ─── Injection sugar — context engineering surface ───────────
//
// ALL of these push into the same `injectionList`. The Injection
// primitive is identical across flavors; the methods are just
// narrative-friendly aliases. Duplicate ids throw at build time.
/**
* Register any `Injection`. Use this for power-user / custom flavors;
* for built-in flavors use the typed sugar (`.skill`, `.steering`,
* `.instruction`, `.fact`).
*
* An Injection carrying `inject.messages` is ROUTED here, not refused
* (7.19.1 refused it; 7.21.0 delivers it). What still gets refused is the
* pair the wire cannot take: a `role: 'tool'` message has no tool call to
* answer, so it is rejected here, at the declaration, on every provider.
* A role the ATTACHED provider cannot carry is a different question — it
* depends on the provider, which this builder does not have — so it is
* refused at run start instead, by name. This is the one funnel every
* flavor passes through, so a hand-built Injection cannot go around the
* checks the named factories make.
*/
injection(injection) {
if (this.injectionList.some((i) => i.id === injection.id)) {
throw new Error(this.duplicateRefusal('injection id', injection.id, this.injectionSources, 'Agent.injection()'));
}
for (const msg of injection.inject?.messages ?? []) {
if (msg.role === 'tool') {
throw new Error(messagesToolRoleRefusal(`Agent.injection('${injection.id}')`));
}
}
assertReadSkillActivation(injection);
this.injectionList.push(injection);
this.injectionSources.set(injection.id, this.currentSource());
return this;
}
/**
* Register a Skill — LLM-activated, system-prompt + tools.
* Auto-attaches the `read_skill` activation tool to the agent.
* Skill stays active for the rest of the turn once activated.
*/
skill(injection) {
return this.injection(injection);
}
/**
* Bulk-register every Skill in a `SkillRegistry`. Use for shared
* skill catalogs across multiple Agents — register skills once on
* the registry; attach the same registry to every consumer Agent.
*
* @example
* const registry = new SkillRegistry();
* registry.register(billingSkill).register(refundSkill);
* const supportAgent = Agent.create({ provider }).skills(registry).build();
* const escalationAgent = Agent.create({ provider }).skills(registry).build();
*/
skills(registry) {
for (const skill of registry.list())
this.injection(skill);
return this;
}
/**
* Mount a declarative **skill graph** (proposal 002) — each skill carries a
* graph-derived trigger (entry → always/rule, deterministic route → rule /
* on-tool-return), so dynamic token-efficient loading becomes *declared* and
* *drawable*. Pure sugar over `.injection()` — `graph.toMermaid()` renders the
* topology.
*
* The optional second argument (SG-C, 9.17.0) sets the MOUNT's routing
* posture and cursor span — see {@link SkillGraphOptions}. Omitted, the
* agent behaves byte-for-byte as it always has.
*
* @example
* const graph = skillGraph()
* .entry(triage)
* .route(triage, sfp, { when: (r) => r.toolName === 'get_counters' && JSON.parse(r.result).crc > 0 })
* .build();
* Agent.create({ provider }).skillGraph(graph).build();
*
* @example
* // The conversation keeps its place across turns, and the model may
* // route only when the router declared ambiguity:
* Agent.create({ provider })
* .skillGraph(graph, { continuity: 'conversation', strictness: 'guard' })
* .build();
*/
skillGraph(graph, options) {
// Classic ReAct caches the system-prompt and tools slots after turn 1 (the
// Context selector's `includeStatic`), while the injection engine — the loop
// target — keeps running every iteration. A graph mounted on that mode
// ADVANCES: routes fire, the cursor moves, `context.evaluated` honestly
// reports skills activating — and none of it ever reaches the model,
// because the slots that would carry the activated body and unlocked tools
// were composed once and never again. Config that lies. Refused at build
// (9.16.0), like `.selfExplain()` under classic and for the same caching
// reason; `reactMode` is fixed at `Agent.create`, so this check is
// order-safe wherever `.skillGraph()` appears in the chain.
if (this.opts.reactMode === 'classic') {
throw new Error("AgentBuilder.skillGraph: reactMode 'classic' cannot honor a skill graph. " +
'Classic caches the system-prompt and tools slots after turn 1, so a route-driven ' +
"activation moves the cursor and shows up in the trace but the activated skill's " +
'body and tools never reach the model — the run would record routing that the wire ' +
"never saw. Use the default 'dynamic' mode (or 'dynamic-grouped'), which recomposes " +
'the slots every iteration. Classic remains fine WITHOUT a graph: a fixed system ' +
'prompt, fixed tools, and always-on steering all compose on turn 1 and stay valid.');
}
// One agent routes with ONE graph. The second call used to replace the cursor,
// the reachable set and the entry scorer while the FIRST graph's skills stayed
// registered and active — so graph 1's route targets could never activate again
// (their ids are absent from graph 2's reachable set, so even a model pick is
// refused) and only its unconditional entries survived, as always-on bodies with
// dead wiring. Nothing about that is recoverable at runtime, so it is refused
// here (8.4.0).
if (this.skillGraphNextSkill !== undefined) {
throw new Error('Agent.skillGraph(): a skill graph is already mounted, and one agent routes with ' +
'ONE graph. The second call replaces the cursor, the reachable set and the entry ' +
"scorer — the first graph's skills stay registered and active, so its own routes " +
'could never fire again. Merge the two graphs into one skillGraph(...) ' +
'declaration, or build one agent per graph.');
}
for (const skill of graph.skills)
this.injection(skill);
// Capture the cursor resolver so the Injection Engine can `from`-gate route
// triggers against the persisted `currentSkillId`. `nextSkill` is REQUIRED
// (every `skillGraph().build()` supplies it). Pass the full `build()` result
// here; for a bare skill list use `.skills({ list })` instead.
this.skillGraphNextSkill = graph.nextSkill;
// The reachable-set resolver gates read_skill to in-graph jumps (optional for
// forward-compat with graphs built before reachableSkills existed).
this.skillGraphReachable = graph.reachableSkills;
// The relevance entry scorer (present only with `.entryByRelevance()`).
this.skillGraphScoreEntries = graph.scoreEntries;
// Which skills the graph WIRES (any declared incoming edge, deterministic or
// bare). The gate uses it to leave a bare model edge `A → M` from-gated while
// opening skills the graph never mentions.
this.skillGraphEdgeTargets = (graph.edges ?? []).map((e) => e.to);
// The clause-reporting resolver (8.5.0) and the one fact the gate's refusal
// needs beyond the reachable set: is this a tree?
this.skillGraphExplainNextSkill = graph.explainNextSkill;
// The suppression reporter (8.15.0) — what the cursor law kept off the wire.
this.skillGraphSupersededEntries = graph.supersededEntries;
this.skillGraphIsTree = (graph.nodes ?? []).some((n) => n.kind === 'predicate');
// The node-id set (9.19.0) — the brains check-up's map. Captured on every
// mount; undefined when the graph object carries no `nodes` (a
// structurally-typed graph), in which case declaring brains is refused
// at build rather than validated against a map that does not exist.
if (graph.nodes !== undefined) {
this.skillGraphNodeIds = new Set(graph.nodes.flatMap((n) => (typeof n.id === 'string' ? [n.id] : [])));
}
// The DECLARED map (9.50.0) — nodes + edges verbatim, descriptions from the
// compiled skills. Projected ONCE here (the graph object is in hand only at
// mount) so `createExecutor` can file it per run without holding the graph.
this.skillGraphDeclared = buildSkillGraphDeclared(graph, graph.skills);
// The brains options (9.19.0) — captured verbatim; folded + validated at
// build(), where the final injection list (the other home) exists.
if (options?.providers !== undefined ||
options?.escalation !== undefined ||
options?.decider !== undefined) {
this.skillGraphBrainOptions = {
...(options.providers !== undefined && { providers: options.providers }),
...(options.escalation !== undefined && { escalation: options.escalation }),
...(options.decider !== undefined && { decider: options.decider }),
};
}
// ── The mount options (SG-C): posture + cursor span ────────────────────
const strictness = options?.strictness ?? 'assist';
const continuity = options?.continuity ?? 'turn';
if (!['assist', 'guard', 'rails'].includes(strictness)) {
throw new Error(`Agent.skillGraph: strictness '${String(strictness)}' is not a posture this library ` +
`has. The three are 'assist' (today's gate — reachable picks admitted, divergence ` +
`recorded), 'guard' (picks only from an offered menu) and 'rails' (the model never ` +
`routes; rules/scorer/routes do).`);
}
if (!['turn', 'conversation'].includes(continuity)) {
throw new Error(`Agent.skillGraph: continuity '${String(continuity)}' is not a span this library ` +
`has. 'turn' (default) — the cursor is per-run, exactly as always; ` +
`'conversation' — the turn's final cursor rides the conversation checkpoint and ` +
`becomes the default entry when that conversation is continued.`);
}
if (continuity === 'conversation' && this.skillGraphIsTree) {
throw new Error("Agent.skillGraph: continuity: 'conversation' cannot be honored by a decision " +
'.tree() — a tree routes by predicate on every iteration and has no cursor to ' +
"carry between turns. Use the flat entry/route form, or keep continuity: 'turn'.");
}
if (strictness === 'rails' && graph.entrySelection === 'model-read') {
throw new Error("Agent.skillGraph: strictness: 'rails' cannot honor .entryByRead() — that mode's " +
'entire entry mechanism IS a model pick, and rails refuses model routing. Rank the ' +
'entries with .entryBy(keywordScorer()) / .classify(...), declare start rules, or ' +
"drop to strictness: 'guard' (the model picks only from an offered menu).");
}
// The cascade needs the graph's turn-routing plan — a graph built by this
// version always carries one. A structurally-typed graph without it cannot
// be judged, and accepting the option while wiring nothing would be config
// that lies, so it is refused by name.
const needsPlan = graph.entrySelection === 'classify' || continuity === 'conversation';
if (needsPlan && graph.turnRouting === undefined) {
throw new Error(`Agent.skillGraph: ${graph.entrySelection === 'classify'
? 'this graph declares a classifier'
: "continuity: 'conversation' was declared"}, but the graph object carries no \`turnRouting\` plan — the turn-start cascade ` +
`cannot run without it. Build the graph with this version's skillGraph() (every ` +
`flat graph it builds carries the plan), or drop the option.`);
}
if (strictness !== 'assist' || continuity !== 'turn' || graph.entrySelection === 'classify') {
// Wired only when something NEW was asked for — a bare `.skillGraph(g)`
// on a scorer/read/plain graph stores nothing and changes nothing.
this.skillGraphCascade = {
...(graph.turnRouting !== undefined && { turnRouting: graph.turnRouting }),
strictness,
continuity,
nodeIds: new Set((graph.nodes ?? []).flatMap((n) => (typeof n.id === 'string' ? [n.id] : []))),
};
}
// The deferred body-contract note: the graph built without `knownTools`, so its
// typo-vs-baseline-tool checks wait for `build()`, where the registry is real.
// Library-built graphs also stamp the note on each skill's metadata (that is
// what serves the `.skills({ list })` door); this capture is the fallback for
// a structurally-typed graph without the stamps — `build()` dedupes by id.
if (graph.deferredBodyContract) {
this.skillGraphDeferredBodyContract = {
mode: graph.deferredBodyContract.mode,
skills: graph.skills,
};
}
return this;
}
/**
* Mount the maps kernel (9.58.0) — the layer that owns ENGAGEMENT, the
* axis orthogonal to every map's own cursor.
*
* A mounted map (today: the skill map; the screen map is the next tenant)
* keeps sole ownership of its position. What the kernel owns is whether
* that map's contributions — prompt fragment and tools — ride the next
* call. An engagement founded on a GUESS (an entry regex, a classifier)
* is renewed only by concrete evidence: the map's own tool called, a
* declared route fired, the model asking by name. Without corroboration
* for `renewalGrace` consecutive passes the map is PARKED — its cursor
* stays exactly where the map put it, its contribution stops riding, and
* explicit or structural evidence re-engages it (an accepted `read_skill`
* pick is the recovery door). Every standing change is a typed event:
* `agentfootprint.map.engaged` / `agentfootprint.map.parked`.
*
* Why: in a recorded 30-call turn, an entry regex matched the word "zone"
* inside "find the most recent zone redundancy run" — a noun the person
* wanted to FIND, not a task. The turn stood on an audit skill for all 30
* calls; its 4 tools were never called; ~7k characters of the wrong map
* rode every call of a 359k-token turn. Under the kernel that map parks
* on call four.
*
* Requires a mounted skill map — refused at build() otherwise. Zero-delta
* when absent: no scope key, no events, byte-identical evaluation.
*
* @example
* const agent = Agent.create({ provider, model })
* .skillGraph(myMap)
* .maps({ renewalGrace: 3 })
* .build();
*/
maps(options = {}) {
if (this.mapsOptions !== undefined) {
throw new Error(`Agent.maps(): the kernel is already configured. One kernel per agent — a second ` +
`call would silently replace the first's policy. Merge the options into one call.`);
}
if (options.renewalGrace !== undefined) {
if (!Number.isInteger(options.renewalGrace) || options.renewalGrace < 1) {
throw new Error(`Agent.maps(): renewalGrace must be an integer >= 1, got ` +
`${String(options.renewalGrace)}. It is the number of corroboration-free ` +
`passes a guessed engagement survives before parking.`);
}
}
this.mapsOptions = options;
return this;
}
/**
* WHICH ANSWER FIELDS ARE CLAIMS ABOUT WHICH FACTS (9.61.0) — the claim
* seam's contract.
*
* The evidence gate (`.evidence()`) grounds the answer's names and
* numbers: every value must appear in a tool result. Its own limit is
* stated in its docs — it cannot catch a false claim assembled from real
* values ("fc1/3 is healthy" when the data says the port is down uses
* entirely grounded tokens). This closes that hole for the facts you
* name: the run's semantic envelopes settle typed readings, and each
* declared field of the validated answer is compared against the one it
* claims to report.
*
* DECLARED, NEVER INFERRED — the `argumentsFrom` precedent. The library
* does not guess that an answer field named `nav_count` is about
* `nav(screen2)`; you say so, and the checker joins. Nothing is blocked:
* a disagreement files one `agentfootprint.integrity.context_error` at
* seam `'claim'`, and the answer is returned exactly as it was — unless an
* enforcing `.answerValidation({ agentChecks: ['claims'] })` lists the
* check, which then withholds a contradicted answer.
*
* Requires `.outputSchema()` — without a validated answer object there is
* no typed stratum to read, and prose is never checked. Refused at
* `build()` rather than skipped, because a contract that silently checks
* nothing is worse than no contract.
*
* @example
* const agent = Agent.create({ provider, model })
* .tool(screenTool) // returns describedResult({ facts: [...], provenance })
* .outputSchema(AnswerSchema)
* .claims({ nav_count: { entity: 'screen2', field: 'nav' } })
* .build();
*/
claims(contract) {
if (this.claimsContract !== undefined) {
throw new Error(`Agent.claims(): the claim contract is already declared. One contract per agent — a ` +
`second call would silently replace the first. Merge the fields into one call.`);
}
const entries = Object.entries(contract);
if (entries.length === 0) {
throw new Error(`Agent.claims(): the contract names no fields — omitting the call is how "this agent ` +
`claims nothing checkable" is said.`);
}
for (const [answerField, fact] of entries) {
// An identity edge with a blank half joins the wrong subjects — the
// same refusal `defineTool({ owner })` makes, for the same reason.
if (fact === null ||
typeof fact !== 'object' ||
typeof fact.entity !== 'string' ||
fact.entity.length === 0 ||
typeof fact.field !== 'string' ||
fact.field.length === 0) {
throw new Error(`Agent.claims(): '${answerField}' must name a non-empty entity and field — it is the ` +
`edge the claim check joins on, and a blank half joins the wrong subjects. Got ` +
`${JSON.stringify(fact)}.`);
}
}
this.claimsContract = contract;
return this;
}
/**
* Register a Steering doc — always-on system-prompt rule.
* Use for invariant guidance: output format, persona, safety policies.
*/
steering(injection) {
return this.injection(injection);
}
/**
* Register an Instruction — rule-based system-prompt guidance.
* Predicate runs each iteration. Use for context-dependent rules
* including the "Dynamic ReAct" `on-tool-return` pattern.
*/
instruction(injection) {
return this.injection(injection);
}
/**
* Bulk-register many instructions at once. Convenience for consumer
* code that organizes its instruction set in a flat array (`const
* instructions = [outputFormat, dataRouting, ...]`). Each element
* is registered via `.instruction()` so duplicate-id checks still
* fire per-entry.
*/
instructions(injections) {
for (const i of injections)
this.instruction(i);
return this;
}
/**
* Register a Fact — developer-supplied data the LLM should see.
* User profile, env info, computed summary, current time, …
* Distinct from Skills (LLM-activated guidance) and Steering
* (always-on rules) in INTENT — the engine treats them all alike.
*/
fact(injection) {
return this.injection(injection);
}
/**
* Register a Memory subsystem — load/persist conversation context,
* facts, narrative beats, or causal snapshots across runs.
*
* The `MemoryDefinition` is produced by `defineMemory({ type, strategy,
* store })`. Multiple memories layer cleanly via per-id scope keys
* (`memoryInjection_${id}`):
*
* ```ts
* Agent.create({ provider })
* .memory(defineMemory({ id: 'short', type: MEMORY_TYPES.EPISODIC,
* strategy: { kind: MEMORY_STRATEGIES.WINDOW, size: 10 },
* store }))
* .memory(defineMemory({ id: 'facts', type: MEMORY_TYPES.SEMANTIC,
* strategy: { kind: MEMORY_STRATEGIES.EXTRACT,
* extractor: 'pattern' }, store }))
* .build();
* ```
*
* The READ subflow runs at the configured `timing` (default
* `MEMORY_TIMING.TURN_START`) and writes its formatted output to the
* `memoryInjection_${id}` scope key for the slot subflows to consume.
*/
memory(definition) {
if (this.memoryList.some((m) => m.id === definition.id)) {
throw new Error(`Agent.memory(): duplicate id '${definition.id}' — each memory needs a unique id ` +
'to keep its scope key (`memoryInjection_${id}`) collision-free.');
}
// A memory that calls a model on your behalf — today that is the
// `summarize` strategy — is held to the same separate-instance rule as
// `.compaction()`. `defineMemory` cannot make this check: it has never
// heard of an agent. It declares the billing, this reads it (9.14.0).
this.assertSummarizerIsNotTheAgentItself(definition.billing, `Agent.memory('${definition.id}')`);
this.memoryList.push(definition);
return this;
}
/**
* Register a RAG retriever — semantic search over a vector-indexed
* corpus. Identical plumbing to `.memory()` (RAG resolves to a
* `MemoryDefinition` produced by `defineRAG()`); this alias exists
* so the consumer's intent reads clearly:
*
* ```ts
* agent
* .memory(shortTermConversation) // remembers what the USER said
* .rag(productDocs) // retrieves what the CORPUS says
* .build();
* ```
*
* Both end up as memory subflows, but the alias separates "user
* conversation memory" from "document corpus retrieval" in code
* intent, ids, and Lens chips.
*/
rag(definition) {
return this.memory(definition);
}
/**
* Declarative terminal contract. The agent's final answer must be
* JSON matching `parser`. Auto-injects a system-prompt instruction
* telling the LLM the shape, and exposes `agent.runTyped()` /
* `agent.parseOutput()` for parse + validate at the call site.
*
* The `parser` is duck-typed: any object with a `parse(unknown): T`
* method works (Zod, Valibot, ArkType, hand-written). The optional
* `description` field on the parser drives the auto-generated
* instruction; consumers can also override via `opts.instruction`.
*
* Throws if called more than once on the same builder (avoids
* silent override surprises).
*
* ## What the DEFAULT buys you, and what it does not
*
* `.outputSchema(parser)` on its own means **judge, do not re-ask**: the
* prompt gets the instruction, native structured output is used when the
* effective provider/model and available JSON Schema support it, and the
* answer is validated locally in the loop. Unsupported native schemas or
* providers fall back to instruction plus validation. A
* failure is recorded (`outputAttempts`), announced
* (`agentfootprint.agent.output_contract_unmet`), warned about once, and
* readable afterwards through `agent.outputContractUnmet()`. What it does
* NOT do is spend a turn fixing the answer — pass `{ retries: 1 }` for the
* first real correction. Before 8.18.0 the default judged nothing at all
* inside the run, and a `run()` caller could not tell a contract had been
* missed.
*
* ## The two ways a run with a contract can END
*
* `runTyped()` throws **`OutputSchemaError`** when the answer fails the
* schema — and **`MessageDeniedError`** when an `act({ output })` rule
* refused to release the answer at all. The second one is not a schema
* failure and is never re-asked: the answer was withheld on purpose, and
* asking the model for a better-shaped version of a string nobody is allowed
* to see would route around the rule. A `catch` block that only knows about
* `OutputSchemaError` will miss it.
*
* `run()` throws neither for a schema failure: it returns the raw answer, as
* it always has, and says so through the channels above.
*
* @param parser Validation strategy that throws on shape failure.
* @param opts Optional `{ name, instruction, retries, strategy, jsonSchema }`.
*
* @example
* import { z } from 'zod';
* const Output = z.object({
* status: z.enum(['ok', 'err']),
* items: z.array(z.string()),
* }).describe('A status enum + an array of strings.');
*
* const agent = Agent.create({...})
* .outputSchema(Output, { retries: 1 })
* .build();
*
* const typed = await agent.runTyped({ message: '...' });
* typed.status; // narrowed to 'ok' | 'err'
*/
/**
* Require every **name and number in the final answer** to appear in a tool
* result the run actually read (9.35.0). If one does not, the model typed it
* rather than read it.
*
* ## What it is — and what it provably is not
*
* It is a **fabrication detector, not a correctness judge.** It catches
* invented values. It CANNOT catch a false claim assembled from real values:
* *"fc1/3 is healthy"* when the data says the port is down uses entirely
* grounded tokens, and this check passes it without a murmur. Anyone who
* reads it as a hallucination check will trust it for the one thing it
* cannot do. It is also conservative by design (small numbers, all-letters
* names and units are not examined), because a false accusation costs a real
* turn and can refuse a good answer.
*
* The check is **deterministic** — set membership over normalized tokens. No
* second model, no embedding, no judge. A guard that needed a bigger model
* to police a smaller one would invert this library's whole thesis and would
* fail exactly where the small model is deployed.
*
* ## The three postures
*
* Same three words as `.skillGraph({ strictness })`, deliberately — and a
* SEPARATE setting, because routing authority and evidence discipline are
* different decisions:
*
* • `'assist'` (**default**) — record and flag. The answer goes out
* unchanged; you learn how often it happens before you act on it.
* • `'guard'` — the unsupported values are named back to the model, which
* gets ONE more ordinary turn (tools still on the wire, so it can go and
* fetch what it guessed — except under an ENFORCING `.answerValidation()`,
* where that turn and every later call of the run are served no tools,
* so a tool that writes or sends never runs twice before the answer is
* withheld). Survivors ship flagged. **This is the recommended posture
* for a weaker model.**
* • `'rails'` — the same one revision, then `run()` raises
* `UnsupportedValuesError` rather than return an answer that still
* carries them.
*
* Values the USER supplied — this turn's message, the conversation, the
* system prompt and skill bodies — are exempt without being declared: the
* user gave them, so they were not invented.
*
* Every judgement lands on the emit channel as
* `agentfootprint.agent.evidence_checked`, whatever the posture, so a
* debugger can show the answer, the values and whether the revision fixed
* them. The terminal verdict is readable after the run with
* `agent.unsupportedValues()`.
*
* `nudge: true` adds the staged-refs nudge — the recency half of grounded
* numbers. When an iteration's context holds tool results staged by
* reference (`artifacts.placement` tickets) and a served tool declares
* `wants` over one of their kinds, ONE late line is appended to that
* request naming the refs and the spender: derived numbers come from the
* tool, not from mental arithmetic. Composed from declarations only,
* request-only (never history), recorded as
* `agentfootprint.agent.grounding_nudged`. Off by default.
*
* @example
* const agent = Agent.create({ provider, model })
* .tool(showInterface)
* .namesAndNumbersFromEvidence({
* posture: 'guard',
* // The default extractor guesses from digits and punctuation; teach
* // it your domain's shapes and they are checked by name.
* shapes: [{ name: 'wwn', match: /(?:[0-9a-f]{2}:){7}[0-9a-f]{2}/ }],
* exempt: ['v9.35.0'],
* // Name staged dataset refs + the compute tool late in the context,
* // where recency works FOR the instruction.
* nudge: true,
* })
* .build();
*/
namesAndNumbersFromEvidence(opts) {
if (this.evidenceGate) {
throw new Error('AgentBuilder.namesAndNumbersFromEvidence: already set. Each agent has at most one ' +
'evidence gate — pass every shape and exemption in the one call.');
}
// Resolved (and refused) HERE rather than at build: a posture typo is a
// mistake in the line you just wrote, and the stack should say so.
this.evidenceGate = resolveEvidenceGate(opts);
return this;
}
/**
* Make the limits of an answer travel WITH the answer (this release).
*
* A tool that returns `coverage(verdict, { checked, notChecked,
* cannotCover })` — or `absent({ what, checked, … })` — declares the ground
* its result stands on. With this on, the run's declarations are folded into
* one block and appended to the final answer, so a reader learns whether
* *"everything looks fine"* means **verified** or **unexamined**. (A typed
* answer gets the same limits as data instead — see below.)
*
* ## Why appended, and not asked for
*
* A limit the model is asked to carry is a limit the model can drop, and
* dropping it is invisible: an answer with no caveat and an answer whose
* caveat was omitted read identically. The block is therefore composed by
* the framework from what the tools declared and concatenated onto the
* answer — the model does not write it, so the model cannot drop it. The
* price is that it changes the bytes of the answer, which is why it is off
* by default.
*
* **What it is not.** It does not judge whether the model stated the limits
* in its own prose, and it does not refuse an answer that did not. Both
* would need a second model to decide what counts as "stated", which is the
* one thing this library will not put in a guard (see
* `.namesAndNumbersFromEvidence()` and `core/agent/evidence/README.md`).
*
* Off → byte-identical: nothing is appended and the final branch mounts the
* stage function it has always mounted. The RECORDING half runs either way
* (`agentfootprint.tools.coverage_declared` / `.absent`, and
* `coverageDeclared` in the snapshot), so you can measure how often your
* tools declare limits before you decide to ship them.
*
* ## With an output schema, the limits come back as data
*
* A typed answer (`.outputSchema()`) is JSON, and JSON with a block of prose
* after it is not JSON — so nothing is appended to it. The answer stays
* exactly what the model sent, `runTyped()` parses it, and the same limits
* the block would have listed come back beside it:
* `agent.answerCoverage()` (`{ checked, notChecked, cannotCover }`),
* `turn_end.answerCoverage`, and `answerCoverage` in the snapshot.
*
* @example
* const agent = Agent.create({ provider, model })
* .tool(replicationHealth) // returns coverage(verdict, { … })
* .limitsTravelWithTheAnswer()
* .build();
*
* @example A typed answer — the limits as data
* ```ts
* const typed = Agent.create({ provider, model })
* .tool(replicationHealth)
* .outputSchema(Verdict)
* .limitsTravelWithTheAnswer()
* .build();
* const verdict = await typed.runTyped({ message: 'is replication healthy?' });
* const limits = typed.answerCoverage(); // undefined when no tool declared any
* ```
*/
limitsTravelWithTheAnswer() {
// Refused rather than shrugged at, like every other one-per-agent policy
// (`toolsFromActiveSkill` verbatim): a second call means the author
// believes there are two of these to set, and the honest answer is that
// there is one boundary block per answer.
if (this.limitsTravelValue) {
throw new Error('AgentBuilder.limitsTravelWithTheAnswer: already set. One agent appends one coverage ' +
'block to one answer, folded from every declaration its tools made — so a second ' +
'call has nothing left to say. Drop it.');
}
this.limitsTravelValue = true;
return this;
}
/**
* Make the script a code-running call SUBMITTED travel with the answer,
* attached by the library from the run record and labelled from the
* execution record — so "always show the script" holds without the model's
* cooperation, and the model never retypes a program that would then need
* checking.
*
* A tool is code-running because it DECLARES so (`Tool.script`;
* `codeRunnerTool` declares `{ argument: 'code', language }`). Each call of
* one files a `script` row: the program handed to the tool (after any
* before-tool middleware) and what its runner reported. With this on:
*
* - `turn_end.scripts` carries the attached scripts as data on every answer
* — a host with its own renderer reads it and never parses the text;
* - a PROSE answer also carries them as text after its `---` separator, one
* block per script: a label line ("Script submitted to run_code on call c1
* — completed, exit 0. Attached by the library from the run record:"),
* then the program in the library's own fence, byte for byte. A typed
* answer (`.outputSchema()`) gets the data only.
*
* The label says only what the record proves: `completed, exit 0` needs an
* explicit exit code; a bare runner verdict reads "runner reported success;
* no exit code"; a timeout, a cancellation or a service error with no exit
* code reads "outcome unknown"; a tool that returned without runner facts
* reads "tool call returned; execution not confirmed". A result withheld
* over the tool's ceiling does not undo a completed execution — both are
* said. A program over `maxBytes` (default 4,000 UTF-8 bytes) is cut at a
* code-point boundary and says it is incomplete.
*
* `which: 'last'` (default) attaches the last call of the turn that reached
* the tool; `'all'` every such call, at most 8, the rest counted on
* `turn_end.scripts.omitted`. The attached text is part of the answer the
* app chose to show: `run()` returns it and memory keeps it.
*
* Refused at build when no registered tool declares `script` (and no
* `ToolProvider` could serve one): an arm that could never attach anything
* is a misconfiguration, not a no-op.
*
* @example
* ```ts
* const agent = Agent.create({ provider, model })
* .tool(codeRunnerTool({ runner: localCodeRunner() }))
* .namesAndNumbersFromEvidence({ posture: 'guard', code: 'match' })
* .scriptTravelsWithTheAnswer({ which: 'last', maxBytes: 4000 })
* .build();
* ```
*/
scriptTravelsWithTheAnswer(opts) {
if (this.scriptTravelValue !== undefined) {
throw new Error('AgentBuilder.scriptTravelsWithTheAnswer: already set. One agent attaches its scripts ' +
'one way — pass `which` and `maxBytes` in the one call.');
}
this.scriptTravelValue = resolveScriptTravel(opts);
return this;
}
/**
* Mount the INPUTS LAYER (honesty layer 2) for ruled tools the build cannot
* see — tools a `ToolProvider` serves.
*
* A tool that declares `askOrAssume` (`defineTool({ …, askOrAssume: { window:
* { assume: '2h' } } })`) arms the layer by itself when it is REGISTERED on
* the agent (`.tool()`, a skill's tools, an MCP tool registered here): the
* library fills a value the call leaves out, files it on the findings ledger
* as `default`, tells the model in a note on the result, and the answer's
* standing says the value was assumed. A ToolProvider's list is only known
* per iteration, so the build cannot see its ruled tools — and a ruled tool
* met on an agent without the layer is REFUSED rather than run unruled,
* because configured-and-inert looks exactly like configured-and-working.
* This one line mounts the layer so those tools' rules apply.
*
* `argumentSources: true` arms DECLARED SOURCES without the findings
* ledger: each ruled tool's served schema carries the reserved `_findings`
* argument with `from` alone — where each argument value came from (the
* person's words as a `quote`, a result's `id`, an earlier answer, the app,
* or `'assumed'`) — and the layer CHECKS each claim before the batch runs;
* under an `ask` rule a value the checks do not trace is asked of the
* person. The same checks and rows as `.findings({ argumentSources: true })`,
* without the ledger's `_findings` schema on every tool (about 2,500
* characters each) — `.findings()` beside it serves both, exactly as that
* door does.
*
* Off → the chart is byte-identical (nothing mounted, decorated, read or
* written).
*
* @example
* const agent = Agent.create({ provider, model })
* .toolProvider(fleetTools) // lists a tool that declares askOrAssume
* .inputsLayer()
* .build();
*
* @example
* // The model says where each ruled value came from; the library checks it.
* const agent = Agent.create({ provider, model })
* .tool(searchLogs) // declares askOrAssume: { window: { ask, choices } }
* .inputsLayer({ argumentSources: true })
* .build();
*/
inputsLayer(options) {
if (this.inputsLayerValue) {
throw new Error('AgentBuilder.inputsLayer: already set. One agent mounts one inputs layer, over every ' +
'ruled tool it can call — a second call has nothing left to add. Drop it.');
}
if (options !== undefined && (options === null || typeof options !== 'object')) {
throw new Error(`AgentBuilder.inputsLayer: expected an options object or nothing, got ${typeof options}.`);
}
const argumentSources = options?.argumentSources;
if (argumentSources !== undefined && typeof argumentSources !== 'boolean') {
throw new Error(`AgentBuilder.inputsLayer: argumentSources must be true or false, got ` +
`${JSON.stringify(argumentSources)}.`);
}
this.inputsLayerValue = true;
this.inputsLayerSourcesValue = argumentSources === true;
return this;
}
/**
* Mount the RESULTS LAYER (honesty layer 3) for periods the build cannot
* see coming — a `ToolPeriod` on a tool a `ToolProvider` serves, or a
* `period` a tool declares only on its results.
*
* A result says what time its read covered: `absent({ …, period })`,
* `coverage(value, { …, period })` or `describedResult({ …, period })` —
* the instants it `queried`, and what the store `held` (or `'unknown'`, said
* out loud). The layer runs at the loop head, reads the batch just run, and
* files ONE verdict per call on the findings ledger (`kind: 'period'`):
* `covered`, `partly-held`, `not-held`, `unknown` — or `undeclared`, when
* the tool declares a `ToolPeriod` and the result said nothing about what its
* read covered. The answer's standing (`agent.assessment()`) reads "not
* sure" for every verdict but `covered`. It serves the model nothing: the
* period is already in the result the model read.
*
* A REGISTERED tool that declares a `ToolPeriod` (`defineTool({ …, period:
* { argument: 'window' } })`) arms the layer by itself. Without the layer, a
* declared period is still recorded (`coverageDeclared`, the events, the
* `Period:` line under `.limitsTravelWithTheAnswer()`) — but no verdict is
* filed, and one dev warning per tool says so.
*
* Off → the chart is byte-identical (nothing mounted, read or written).
*
* @example
* const agent = Agent.create({ provider, model })
* .tool(backupRuns) // returns absent({ …, period }) / describedResult({ …, period })
* .resultsLayer()
* .build();
* await agent.run({ message: 'Any failed backups in the last hour?' });
* (await agent.assessment())?.reasons; // [{ reason: 'period-not-held', layer: 3, … }]
*/
resultsLayer() {
if (this.resultsLayerValue) {
throw new Error('AgentBuilder.resultsLayer: already set. One agent mounts one results layer, over every ' +
'call it runs — a second call has nothing left to add. Drop it.');
}
this.resultsLayerValue = true;
return this;
}
/**
* Mount the ANSWER LAYER (honesty layer 4): every answer gets its standing,
* folded from the run's committed record — never from how sure the model
* sounded — and served as data.
*
* The standing is one of five words: `known` (a supporting row: a passed
* `.answerValidation()` report for these exact bytes), `consistent` with the
* run's record (checks ran and none fired — never "verified"), `not-sure`
* (a reason fired: an assumed argument, an empty result that did not say
* what it searched, a declared gap, an unsupported value, a limit that cut
* the turn short, …), `ask`, or `not-assessed` (nothing on the record could
* be checked). It is the same fold as `agent.assessment()` and
* `assessAnswer(recording)`, run once inside the run at the head of the
* final branch, so the in-run standing equals the one a later reader folds.
*
* Armed, three things happen and no others:
*
* - `turn_end.answerAssessment` and one `agentfootprint.answer.assessed`
* event carry the value, its rendering, the reason kinds and the checks
* that ran — never a value from the answer or a quote;
* - the Route decider files two witness rows on the findings ledger, so the
* fold can read them: the evidence gate's clean pass (`grounded` — the
* names-and-numbers check RAN; it never supports "known") and an answer
* given before a skill's declared steps finished (`steps-unfinished`);
* - every row the findings ledger gets carries its conversation `turn`.
*
* It is a LABEL: it never asks, never refuses and never changes the model's
* reply. The one exception is opt-in — `{ standingLine: true }` appends ONE
* line to a prose answer (`Not sure — window = "2h" was assumed by
* search_logs's rule, not given by you.`), after the limits separator.
* Refused beside `.answerValidation()` (which judges the exact bytes
* delivered) and beside `.outputSchema()` (a typed answer is JSON, and JSON
* followed by prose is not) — through this door and `AgentOptions.answerLayer`
* alike; the standing still travels as data there. The line, like the
* limits block, is appended to the answer the run returns — never streamed
* on `stream.token`.
*
* `{ standingLine: { when } }` says the line only when it matters:
* `'always'` (what `true` means), `'unless-consistent'` (not for
* "consistent with the run's record"), or `'not-sure-or-ask'` (only "not
* sure" and "ask" — not "consistent", "not assessed" or "known"). `ask` is
* said in every mode. The mode decides the LINE only: the standing travels
* as data in every mode (`turn_end.answerAssessment`, the event), the limits
* block (`.limitsTravelWithTheAnswer()`) is appended whether or not the line
* is, and memory keeps exactly the answer that was delivered. **No line is
* never a check:** under `'not-sure-or-ask'` it means no reason fired, which
* includes "nothing could be checked" — and an app that hides both the line
* and the data leaves the person unable to tell "checked" from "not
* assessed".
*
* @example Say it only when it matters
* Agent.create({ provider, model })
* .tool(searchLogs)
* .answerLayer({ standingLine: { when: 'not-sure-or-ask' } })
* .build();
*
* The in-run fold reads no app declarations: `agent.assessment(declarations)`
* and the answer account take `rowsAt`, and can say more than the run did.
*
* Off → the chart, the committed rows, the events and the answer are
* byte-identical.
*
* @example
* const agent = Agent.create({ provider, model })
* .tool(searchLogs)
* .answerLayer() // the standing as data
* .build();
* agent.on('agentfootprint.answer.assessed', (e) => console.log(e.payload.standing));
*
* @example One line to the person, too
* Agent.create({ provider, model }).tool(searchLogs).answerLayer({ standingLine: true }).build();
*/
answerLayer(options) {
if (this.answerLayerValue !== undefined) {
throw new Error('AgentBuilder.answerLayer: already set. One agent folds one standing per answer — ' +
'a second call has nothing left to add. Drop it (pass { standingLine: true } once).');
}
if (options !== undefined && (typeof options !== 'object' || options === null)) {
throw new Error('AgentBuilder.answerLayer: options must be an object — ' +
"{ standingLine?: boolean | { when: 'always' | 'unless-consistent' | 'not-sure-or-ask' } } " +
'— or omitted.');
}
this.answerLayerValue = answerLayerArm(options ?? {}, 'AgentBuilder.answerLayer');
return this;
}
/**
* Arm the TIME LAYER's clock: every run declares when "now" is and which
* zone the person is in, and the run records it.
*
* - **The zone is per run.** `run({ message, time: { zone } })` names the
* person's zone; `zone` here is an optional FALLBACK for a run that names
* none. With neither, the zone is UNKNOWN — the server's zone is never
* used and none is guessed: instants are spelled in UTC and said to be,
* the person is asked their zone before any time they wrote is read, and
* the zone they answer holds for the next turns. A zone is an IANA name
* (`'America/Los_Angeles'`, `'UTC'`); an abbreviation (`PST`) or a bare
* offset names no zone and is refused.
* - **`now` is the app's** (`time: { now }` — the message's time), else the
* turn's start, recorded as a default (`nowSource: 'default'`).
* - **The model is told the date.** Every request ends with the library's
* one time line, opening with the turn's clock ("This turn's time: Friday
* 2026-10-09 08:40 America/Los_Angeles (UTC-07:00).").
* - **Recorded once per turn.** Seed files one `clock` row on the ledger
* (`agent.findings()`): `now`, `nowSource`, `zone`, `zoneSource`, and a
* window set in a UI (`time: { window }`, recorded `source: 'control'`).
* Each dispatched call files a `call` row with `dispatchedAt`.
* - **Frozen across a pause.** `resume(checkpoint, answer, { time })` keeps
* the paused turn's clock; a `time` that differs is recorded as a
* `clock-on-resume` row, never applied.
* - **The limits in the person's zone.** Under `.limitsTravelWithTheAnswer()`,
* every `Period:` line is rendered in the clock's zone with the zone named
* (`2026-10-09 08:00–08:40 America/Los_Angeles (UTC-07:00)`); the typed
* record keeps the instants as declared.
* - **The person's words, read by YOUR reader** (`reader`, no default). A
* `TimeReader` returns zone-less parts (`'10/09/26'` → three numbers, the
* order undecided); the library resolves them against the clock into
* every candidate window and files one `time-reading` row per mention —
* once per turn, only on a message a person wrote, never re-read on a
* resume or a retry. `policy` picks among the candidates: `dateOrder`
* (`'ask'` default, or `'MDY'` / `'DMY'` / `'YMD'`) and `year` (`'ask'`
* default, or `'current'`); a pick is recorded as assumed. A
* `kind: 'model'` reader's window is never taken as the person's words:
* it waits for the person to confirm it.
* - **The time ask checks the answer** (a `requestInput` field with a
* `format`: `'instant'`, `'time-range'` or `'zone'`). A resume whose answer
* has no offset, ends before it starts, names no zone, or — under
* `.time()`, which knows the person's zone — writes a wall time the clocks
* skip is not taken: the ask comes back with `refused: { answer, reason }`
* and `repeat: { count }`, and nothing runs. The reason is a catalog
* sentence (`defaultTimeAskMessages`); `messages` overrides any key.
*
* Off → nothing is filed, read or rendered differently, and `time` on a run
* is refused (a door that ignored it would look configured and do nothing).
*
* @example
* const agent = Agent.create({ provider, model })
* .tool(searchLogs)
* .time({ zone: 'America/Los_Angeles' }) // the fallback; each run's own zone wins
* .limitsTravelWithTheAnswer()
* .build();
* await agent.run({ message, time: { now: sentAt, zone: session.zone } });
*
* // With a reader: the words are read once per turn, and recorded.
* Agent.create({ provider, model }).time({ reader: myReader, policy: { dateOrder: 'MDY' } });
*/
time(options) {
if (this.timeValue !== undefined) {
throw new Error('AgentBuilder.time: already set. One agent has one clock configuration — pass the ' +
'fallback zone, the reader and its policy once.');
}
const read = readTimeOptions(options);
if ('problem' in read)
throw new Error(`AgentBuilder.time: ${read.problem}.`);
this.timeValue = read.value;
return this;
}
/**
* Offer a skill's tools **only while that skill is active** (9.36.0). One
* line, for every skill on the agent.
*
* ## What it fixes
*
* By default a skill's `tools` go into the agent's STATIC tool list at build
* time, so the model can see and call them from iteration 1 — activated or
* not. Narrowing that was a per-skill field (`defineSkill({ autoActivate:
* 'currentSkill' })`) you had to remember on every skill; the one you forgot
* kept its tools on the wire for the life of the agent, and nothing said so.
* This says it once, for all of them.
*
* With it on, a skill's tools enter the request on the iterations where the
* skill is active — through the same readmission path `autoActivate` has used
* since v2.5 — and nowhere else. Everything else is untouched: `read_skill`,
* `list_skills`, your `.tool()` registry, provider tools and every other
* active skill's tools stay offered, because a scoped agent still has to
* handle the input nobody imagined.
*
* ## Not a posture dial, and why
*
* `.skillGraph({ strictness })` and `.namesAndNumbersFromEvidence({ posture })`
* take three values because there is a real middle there — record it, revise
* it, refuse it. The wire has no middle: a tool's schema is either in the
* request or it is not, and "record that we sent it" is just sending it. A
* three-value dial here would ship one behaviour under two names.
*
* ## What it does NOT do
*
* It governs the OFFER, not dispatch. A tool stays resolvable by name so an
* active skill's call lands — the split `autoActivate` has always had. If you
* need execution itself gated (an inactive skill's tool refused even when the
* model names it from a restored transcript), that is a `PermissionChecker`
* or a `gatedTools` provider, and it is a different question: authority to
* run, not what the model was shown.
*
* ## Interaction with the per-skill flag and with `scopeTools`
*
* All three stamp the same field, and none can contradict another:
* `autoActivate` has one legal value, so a skill can ask to be scoped and can
* never ask to be exempt. A skill that declared its own keeps it; the graph's
* `scopeTools: true` fills in the skills it wires; this fills in the rest.
* Turning it on can only remove tools from the static list, never add one.
*
* **Opt-in.** The default is unchanged — an agent that never calls this
* keeps additive tool visibility. Version 10 retires old APIs without
* changing this active routing policy.
*
* @example
* const skills = await skillsFromDir('./skills', { tools: [lookupOrder, issueRefund] });
* const agent = Agent.create({ provider, model })
* .skills({ list: () => skills })
* .toolsFromActiveSkill() // billing's tools appear when billing does
* .build();
*/
toolsFromActiveSkill() {
// Refused rather than shrugged at, like every other one-per-agent policy:
// a second call means the author believes there are two of these to set,
// and the honest answer is that there is one.
if (this.toolsFromActiveSkillValue) {
throw new Error('AgentBuilder.toolsFromActiveSkill: already set. It is one posture for the whole ' +
"agent — every skill's tools follow that skill's activation — so a second call " +
'has nothing left to say. Drop it.');
}
this.toolsFromActiveSkillValue = true;
return this;
}
outputSchema(parser, opts) {
if (this.outputSchemaParser) {
throw new Error('AgentBuilder.outputSchema: already set. Each agent has at most one terminal contract.');
}
this.outputSchemaParser = parser;
this.outputSchemaRetries = resolveRetries(opts?.retries);
this.outputSchemaStrategy = opts?.strategy ?? 'auto';
if (opts?.jsonSchema !== undefined)
this.outputSchemaJson = opts.jsonSchema;
const instructionText = opts?.instruction ?? buildDefaultInstruction(parser);
const id = opts?.name ?? 'output-schema';
// Always-on system-slot instruction. Activates every iteration so
// long runs keep the contract present (recency-first redundancy).
this.injectionList.push(defineInstruction({
id,
activeWhen: () => true,
prompt: instructionText,
}));
// The SECOND door into the injection list (`.injection()` is the other), so
// it records provenance too: without this the id would read as
// "unattributed" in a later collision — an honest answer, but a worse one
// than the true "the recipe/app that called .outputSchema() mounted it".
this.injectionSources.set(id, this.currentSource());
return this;
}
/**
* The findings ledger (9.101.0) — the model's OWN standings on its tool
* results, on the record, at zero extra calls.
*
* WHY. A long tool-using run serves every result back in full on every
* call, and what the model already stood on, left open, ruled out or found
* to be noise is nowhere but in its head. With this door every served tool
* schema carries a reserved optional `_findings` argument: the model
* declares a `basis` for each call before the result exists ('direct' when
* it expects the answer, 'exploratory' when it is looking) and, on its next
* tool call or as a top-level `_findings.previous` on a JSON answer, the
* STANDING of each previous result by its tool_result id — 'fact' with the
* assertions it stands on, 'open' with what would settle it, 'ruled-out'
* with one line, 'noise' with nothing. The library peels the argument off
* before anything else reads the call (middleware, validation, execute and
* every pause carrier see the call as if the key were never there), files
* one flat append-only `findingsLedger` on the run's state through ONE
* writer, and leaves history verbatim. Read it back with
* {@link Agent.findings}. Nothing infers: a call with no declaration files
* no row, and a result nobody named has no standing — never 'open'.
*
* WHAT THE ANSWER TURN IS SERVED. Once the model has declared a standing,
* every later call is served a request-only system piece composed from the
* folded ledger (`findings/serve.ts · findingsLedgerPiece` — facts,
* limitations, evidenceRefs and nextSteps, each marked "declared by the
* model"; results nobody named listed as undeclared, never open), and on
* the wire only — `history` never changes — a result the model judged
* `noise` or `ruled-out` is replaced by a ticket (`collapseJudged`).
* `serve` chooses how much of the pile stays: `'ledger-and-facts'` (the
* default) keeps fact, open and undeclared results verbatim beside the
* piece; `'ledger-only'` collapses fact results too and is BENCH-GATED —
* shipped so `bench/findings-shuffle.mjs` can measure it on a real model,
* not a recommendation, never a default until that run shows the answer
* does not drift under shuffled evidence. `answerAsk` (9.103.0) appends
* the answer-turn ask (`findings/reserved.ts · FINDINGS_ANSWER_ASK`) to
* that piece under `'quote-facts'` — BENCH-GATED the same way, against
* the fidelity dip the real-model page's fourth run measured; `'none'`
* (the default) writes no key and serves the piece exactly as before.
* `keepLedgerFacts` is the ceiling
* of fact turns the WINDOW holds under standing-aware eviction (9.102.0) —
* the same dial as `AgentOptions.keepLedgerFacts` (that field says why and
* what it costs), this door winning when both are given, resolved once by
* `Agent` at build and threaded to the window stage on an armed agent with
* a window strategy, where `stages/window.ts · buildWindowStage` spends it
* as the `'ledger-fact'` pin ceiling; a negative or non-integer value is
* refused here.
*
* `argumentSources: true` (honesty layer 2, declared sources) — on a tool
* that declares argument rules (`askOrAssume`), the reserved argument also
* carries `from`, first and required: the model says where each argument
* value came from (the person's words as a `quote`, a result's `id`, an
* earlier answer, the app, or `'assumed'`), and the inputs layer CHECKS each
* claim before the batch runs and files the verdict on the call's argument
* rows — under an `ask` rule, a value the checks do not trace is asked of the
* person, and the rule's sentence names `_findings.from`. No instruction line
* is added: `from` explains itself in its own property. It needs the inputs
* layer (a registered ruled tool, or `.inputsLayer()`) and is refused at
* build without it. The same arm WITHOUT this ledger:
* `.inputsLayer({ argumentSources: true })`. Off (the default): nothing is
* served, read or written.
*
* Once per agent (a second call is refused, the `.window()` grammar).
* Registers the always-on `findings-ledger` instruction — the byte-for-byte
* twin of {@link outputSchema}'s piece, hashed per piece on every receipt,
* so a reworded ask is a different hash a bench can name. An agent that
* never calls this is byte-identical to one built before the ledger
* existed: no decoration, no peel, no key, no piece, no event.
*
* @example
* ```ts
* const agent = Agent.create({ provider, model })
* .tool(search)
* .findings()
* .build();
* await agent.run({ message: 'which port is down?' });
* const ledger = agent.findings() ?? [];
* const facts = ledger.filter((r) => r.kind === 'standing' && r.standing === 'fact');
* ```
*/
findings(options) {
if (this.findingsValue !== undefined) {
throw new Error('AgentBuilder.findings: already set. One findings ledger per agent — a second call ' +
'would register the ask twice and leave two option sets with the later one silently ' +
'winning. Pass every option in the one call.');
}
if (options !== undefined && (options === null || typeof options !== 'object')) {
throw new Error(`AgentBuilder.findings: expected an options object or nothing, got ${typeof options}.`);
}
const maxContextBytes = findingsContextBytes(options?.maxContextBytes);
const serve = options?.serve ?? 'ledger-and-facts';
if (serve !== 'ledger-and-facts' && serve !== 'ledger-only') {
throw new Error(`AgentBuilder.findings: serve must be 'ledger-and-facts' or 'ledger-only', got ` +
`${JSON.stringify(serve)}.`);
}
const answerAsk = options?.answerAsk;
if (answerAsk !== undefined && answerAsk !== 'none' && answerAsk !== 'quote-facts') {
throw new Error(`AgentBuilder.findings: answerAsk must be 'none' or 'quote-facts', got ` +
`${JSON.stringify(answerAsk)}.`);
}
const keep = options?.keepLedgerFacts;
if (keep !== undefined && keep !== false && !(Number.isInteger(keep) && keep >= 0)) {
throw new Error(`AgentBuilder.findings: keepLedgerFacts must be a non-negative integer or false, got ` +
`${JSON.stringify(keep)}.`);
}
// The judge (9.104.0) is a port: refused at build unless it has the
// port's two members, so a misconfigured classifier fails here rather
// than as an error row on every tool result.
const judge = options?.judge;
if (judge !== undefined &&
(judge === null ||
typeof judge !== 'object' ||
typeof judge.name !== 'string' ||
judge.name === '' ||
typeof judge.classify !== 'function')) {
throw new Error('AgentBuilder.findings: judge must be a Classifier — `{ name: string; classify(request, ' +
'signal?) }` from agentfootprint/classify (`typesafe()`, `mockClassifier()`, or your own).');
}
// Declared sources (honesty layer 2): a boolean, or nothing. It needs the
// inputs layer, which only the whole agent can say is armed — `Agent`
// refuses it at build when no ruled tool and no `.inputsLayer()` arm one.
// (`.inputsLayer({ argumentSources: true })` arms the same thing without
// this ledger.)
const argumentSources = options?.argumentSources;
if (argumentSources !== undefined && typeof argumentSources !== 'boolean') {
throw new Error(`AgentBuilder.findings: argumentSources must be true or false, got ` +
`${JSON.stringify(argumentSources)}.`);
}
// `answerAsk` is stored as given, never normalised: `'none'` is the
// default and `Agent.ts` threads the dial only under `'quote-facts'`, so
// an explicit `'none'` and an absent one reach the run as the same thing.
this.findingsValue = {
serve,
maxContextBytes,
...(answerAsk !== undefined && { answerAsk }),
...(keep !== undefined && { keepLedgerFacts: keep }),
...(judge !== undefined && { judge }),
...(argumentSources === true && { argumentSources: true }),
};
// The always-on ask — the `outputSchema()` twin: a system-slot instruction
// that activates every iteration, so a long run keeps the vocabulary
// present, and a system PIECE on the receipt (never an injected turn, so
// nothing in it is exempt from the evidence gate and nothing persists).
this.injectionList.push(findingsInstruction(false, answerAsk === 'quote-facts'));
this.injectionSources.set(FINDINGS_INSTRUCTION_ID, this.currentSource());
return this;
}
/**
* Tool choice by classifier (9.105.0) — a SECOND READING of which tool
* answers the current step, filed beside the model's own call, and
* optionally a NARROWING of what is served.
*
* At every model call the tools slot asks `classifier` one `choice`
* question over the tools it is about to serve — the merged wire minus the
* always-served doors, each by its own description — and files a
* `ToolChoiceRow` under `AgentState.toolChoices`: the provider's ranking
* (its distribution as sent, an unscored tool absent), its pick, its
* confidence, its cost, and the names actually served. After the reply
* `callLLM` files a `ToolChoiceOutcomeRow`: the tools the model called,
* whether the first is the pick (`firstAgrees`), and any `miss`. The
* model's call is never overridden; a failed classifier call is a
* `ToolChoiceErrorRow` and the full set is served (fail open).
*
* `serve: 'all'` (the default) is advisory: the wire is byte for byte what
* it was. `serve: { top: N }` narrows the served list to the top-N plus the
* doors (`read_skill`, `list_skills`, `skip_step`, `present`, and
* `alwaysServe`), and serves the full wire with the reason on the row when
* the classifier failed or scored fewer than N (`unavailable`), fewer than
* N + 1 candidates were offered (`too-few`), the previous call missed
* (`after-miss`) or the call is the wrap-up (`wrap-up`). A miss — the model
* naming a narrowed-away tool — is recorded, answered by the dispatcher's
* off-wire path as before, and the next call serves the full wire.
*
* Once per agent (a second call is refused, the `.findings()` grammar);
* `reactMode: 'classic'` is refused at build. An agent that never calls
* this is byte-identical to one built before the option existed: no
* classifier call, no key, no event.
*
* @example
* ```ts
* import { mockClassifier } from 'agentfootprint/classify';
*
* const agent = Agent.create({ provider, model })
* .tool(lookup)
* .tool(charge)
* .toolChoice({ classifier: typesafe(), serve: { top: 2 } })
* .build();
* await agent.run({ message: 'refund order 42' });
* agent.getSnapshot()?.sharedState.toolChoices; // pick → served → called, per call
* ```
*/
toolChoice(options) {
if (this.toolChoiceValue !== undefined) {
throw new Error('AgentBuilder.toolChoice: already set. One classifier per agent — a second call would ' +
'leave two option sets with the later one silently winning. Pass every option in the ' +
'one call.');
}
if (options === null || typeof options !== 'object') {
throw new Error(`AgentBuilder.toolChoice: expected an options object, got ${typeof options}.`);
}
const classifier = options.classifier;
if (classifier === null ||
typeof classifier !== 'object' ||
typeof classifier.name !== 'string' ||
classifier.name === '' ||
typeof classifier.classify !== 'function') {
throw new Error('AgentBuilder.toolChoice: classifier must be a Classifier — `{ name: string; ' +
'classify(request, signal?) }` from agentfootprint/classify (`typesafe()`, ' +
'`mockClassifier()`, or your own).');
}
const serve = options.serve ?? 'all';
if (serve !== 'all') {
const top = serve !== null && typeof serve === 'object' ? serve.top : undefined;
if (!(Number.isInteger(top) && top >= 1)) {
throw new Error(`AgentBuilder.toolChoice: serve must be 'all' or { top: <integer ≥ 1> }, got ` +
`${JSON.stringify(serve)}.`);
}
}
const alwaysServe = options.alwaysServe;
if (alwaysServe !== undefined &&
(!Array.isArray(alwaysServe) || alwaysServe.some((n) => typeof n !== 'string' || n === ''))) {
throw new Error('AgentBuilder.toolChoice: alwaysServe must be an array of non-empty tool names, got ' +
`${JSON.stringify(alwaysServe)}.`);
}
this.toolChoiceValue = {
classifier,
serve,
...(alwaysServe !== undefined && { alwaysServe: [...alwaysServe] }),
};
return this;
}
/**
* The declared ontology (9.106.0) — a MAP of what exists and where, never
* a way to fetch it.
*
* WHY. When a tool result does not hold what a question needs, the model
* has nothing to say about where the need WOULD be met — it guesses a
* source, or reports the absence as if nothing anywhere held the term. An
* ontology lets the application declare, once, what each term IS (its
* meaning and unit — the context contract's `domainDefinitions`), how
* terms RELATE, which SOURCE holds a term with the author's own coverage
* sentence, and which registered TOOL reads it from there. The model is
* served that map as data on every call and asked to name the source, the
* tool or the neighbouring node a need points at — as a proposal. The
* library executes nothing through the map, fetches nothing, and never
* decides that a node "has no data": a node with no declared source is
* served as `known, not held here`, which is what the declaration says.
*
* WHAT IT DOES. Takes the frozen `Ontology` `defineOntology` returned
* (`agentfootprint/ontology`); at `.build()` every tool name a node's `via`
* names is checked against the agent's tool registry and a name no
* registry carries is refused, naming the ontology and the tool. At run
* `seed` writes the whole spec once as the run constant
* `AgentState.ontology`, every model call is served ONE request-only
* system piece composed from that key (`ontology/serve.ts ·
* ontologyPiece`, joined after the recovery piece and before the findings
* piece, hashed on the receipt, rebuilt byte-equal by `servedAt`) and
* `agentfootprint.ontology.served` fires per call with identities and
* counts only. Registers the always-on `ontology` instruction
* (`ONTOLOGY_INSTRUCTION`, the `outputSchema()` twin: a system piece on
* the receipt, never an injected turn).
*
* Once per agent (a second call is refused, the `.findings()` grammar). An
* agent that never calls this is byte-identical to one built before the
* map existed: no key, no piece, no instruction, no event.
*
* `options.ask` (9.107.0, the `answerAsk` grammar — a named value, never a
* boolean): `'use-the-map'` (the default) registers the versioned
* `ONTOLOGY_INSTRUCTION` — read the question in the map's terms, look where
* a source and its tool are declared, walk a relation from a term held to
* a term needed, and when a need is unmet name where it would be met,
* never a value off a definition, never the map itself to the person.
* `'none'` serves the map as data and registers no ask of the library's,
* for an application that writes its own through `.instruction()` — the
* map is served either way; only the ask changes, and the served view and
* the receipt show which (the `ontology` injection present or absent).
*
* @example
* ```ts
* import { defineOntology } from 'agentfootprint/ontology';
*
* const map = defineOntology({
* id: 'fleet',
* version: '1',
* sources: { inventory: { meaning: 'the switch inventory export', configured: true } },
* nodes: {
* port: { meaning: 'a physical switch port', sources: [{ source: 'inventory', via: ['lookup_port'] }] },
* port_error_rate: { meaning: 'CRC errors per minute on a port', unit: 'errors/min' },
* },
* edges: [{ from: 'port_error_rate', to: 'port', relation: 'measured-on' }],
* });
* const agent = Agent.create({ provider, model }).tool(lookupPort).ontology(map).build();
* ```
*/
ontology(ontology, options) {
const ask = options?.ask;
if (ask !== undefined && ask !== 'none' && ask !== 'use-the-map') {
throw new Error(`AgentBuilder.ontology: ask must be 'none' or 'use-the-map', got ${JSON.stringify(ask)}.`);
}
if (this.ontologyValue !== undefined) {
throw new Error('AgentBuilder.ontology: already set. One map per agent — a second call would register ' +
'the ask twice and leave two maps with the later one silently winning. Compose one ' +
'declaration and pass it once.');
}
if (ontology === null ||
typeof ontology !== 'object' ||
typeof ontology.id !== 'string' ||
typeof ontology.version !== 'string' ||
typeof ontology.hash !== 'string' ||
ontology.hash.length === 0 ||
!Object.isFrozen(ontology) ||
ontology.nodes === null ||
typeof ontology.nodes !== 'object' ||
ontology.sources === null ||
typeof ontology.sources !== 'object' ||
!Array.isArray(ontology.edges)) {
throw new Error('AgentBuilder.ontology: expected the frozen Ontology `defineOntology(spec)` returns ' +
'(agentfootprint/ontology), not a bare spec — the definition is where the declaration ' +
'is validated and fingerprinted.');
}
this.ontologyValue = ontology;
// `'none'`: the map as data, no ask of the library's — the application
// registers its own through `.instruction()`. Nothing else differs.
if (ask === 'none')
return this;
// The always-on ask — the `outputSchema()` twin: a system-slot instruction
// that activates every iteration, so a long run keeps the vocabulary
// present, and a system PIECE on the receipt (never an injected turn).
this.injectionList.push(defineInstruction({
id: ONTOLOGY_INSTRUCTION_ID,
activeWhen: () => true,
prompt: ONTOLOGY_INSTRUCTION,
}));
this.injectionSources.set(ONTOLOGY_INSTRUCTION_ID, this.currentSource());
return this;
}
/**
* 3-tier degradation for output-schema validation failures. Pairs
* with `.outputSchema()` — an agent that has one and not the other is
* refused at `.build()`, in either call order.
*
* Three tiers:
*
* 1. **Primary** — LLM emitted schema-valid JSON. Caller gets it.
* 2. **Fallback** — `OutputSchemaError` thrown. The async
* `fallback(error, raw)` runs; its return is re-validated.
* 3. **Canned** — static safety-net value. NEVER throws when set.
*
* `canned` is validated against the schema at `.build()` — fail-fast on
* misconfig (a `canned` that doesn't validate would defeat the fail-open
* guarantee at the exact moment it is needed).
*
* ## The tiers run at the TYPED boundary — `run()` does not reach them
*
* `runTyped()` and `parseOutputAsync()` engage the chain. **`run()` does
* not**, and cannot: these tiers produce a typed value `T`, and `run()`
* resolves to the raw answer string — substituting a fallback there would
* hand a caller a different answer than the model gave, invisibly. So an
* agent consumed through `run()` (a server route, a queue worker,
* `standingAgent`) gets NO fallback, and until 8.18.0 nothing said so. Now
* the unmet-contract warning and
* `agentfootprint.agent.output_contract_unmet` both carry
* `fallbackConfigured: true` — the signal that a safety net exists and this
* caller is not standing under it.
*
* Two typed events fire on tier transitions for observability:
* - `agentfootprint.resilience.output_fallback_triggered`
* - `agentfootprint.resilience.output_canned_used` — carries
* `retriesSpent`, and warns when the canned value lands after re-asks
* that were billed. With `canned` set, `runTyped()` is structurally
* unable to throw, so nothing else would report that spend.
*
* @example
* ```ts
* import { z } from 'zod';
* const Refund = z.object({ amount: z.number(), reason: z.string() });
*
* const agent = Agent.create({...})
* .outputSchema(Refund)
* .outputFallback({
* fallback: async (err, raw) => ({ amount: 0, reason: 'manual review' }),
* canned: { amount: 0, reason: 'unable to process' },
* })
* .build();
* ```
*/
outputFallback(options) {
if (this.outputFallbackCfg) {
throw new Error('AgentBuilder.outputFallback: already set. Each agent has at most one fallback chain.');
}
this.outputFallbackCfg = {
fallback: options.fallback,
...(options.canned !== undefined && { canned: options.canned }),
hasCanned: options.canned !== undefined,
};
// The coherence check moved to `build()` in 8.18.0 — see
// `assertOutputFallbackCoherent`. What it needs (a parser) is a fact about
// the FINISHED agent, and the builder is a bag of settings in whatever
// order they were written.
return this;
}
/**
* `.outputFallback()` needs `.outputSchema()`, and it needs it by the time
* the agent exists — not by the time the call is written (8.18.0).
*
* The requirement is set MEMBERSHIP: a fallback is degradation for a
* contract, so an agent with one and not the other is incoherent. Order is
* not the requirement, and refusing on order made a builder whose lines
* could not be reordered without reading an error message to find out —
* `.outputFallback().outputSchema()` threw while `.outputSchema()
* .outputFallback()` was fine, and both end with the same agent.
*
* The `canned` value is validated here for the same reason: validating it
* needs the parser, so it belongs wherever the parser is guaranteed.
*/
assertOutputFallbackCoherent() {
const cfg = this.outputFallbackCfg;
if (cfg === undefined)
return;
if (!this.outputSchemaParser) {
throw new Error('AgentBuilder.build: .outputFallback() is configured but .outputSchema(parser) is not. ' +
'A fallback is what happens when the terminal contract is not met, so without a ' +
'contract there is nothing for it to catch and its tiers can never run. Add ' +
'.outputSchema(parser) — in either order, they are one setting made of two calls — ' +
'or drop the fallback.');
}
// The safety net must always validate; a `canned` value that does not is a
// misconfiguration that would only surface AFTER the agent loop had
// already failed, which is the one moment it exists to survive.
if (cfg.hasCanned) {
validateCannedAgainstSchema(cfg.canned, this.outputSchemaParser);
}
}
/**
* Wire rules-based reliability around every `CallLLM` execution.
* The framework wraps the LLM call in a retry/fallback/fail-fast
* loop driven by `preCheck` and `postDecide` rules.
*
* Decision verbs the rules can emit (see `ReliabilityDecision` for
* the full list):
*
* • `continue` — pre-check OK, proceed to the call
* • `ok` — post-call OK, commit and return
* • `retry` — re-call same provider (bumps `attempt`)
* • `retry-other` — advance to next provider in `providers[]`
* • `fallback` — invoke `config.fallback(req, lastError)`
* • `fail-fast` — throw `ReliabilityFailFastError` at `agent.run()`
*
* **Streaming + reliability semantics — first-chunk arbitration:**
* Pre-first-chunk failures (connection/headers/breaker-open) honor
* the full rule set (retry, retry-other, fallback, fail-fast).
* Post-first-chunk failures (mid-stream) honor only `ok` and
* `fail-fast`; rules wanting `retry`/`retry-other`/`fallback` are
* escalated to fail-fast with kind `'mid-stream-not-retryable'`.
* This matches LangChain's `RunnableWithFallbacks` pattern and
* the prevailing industry default — see the streaming + reliability
* design memo for the full discussion.
*
* Throws if called more than once on the same builder.
*
* @example
* import { Agent } from 'agentfootprint';
* import { ReliabilityFailFastError } from 'agentfootprint/resilience';
*
* const agent = Agent.create({ provider, model: 'mock' })
* .system('Triage support tickets.')
* .reliability({
* postDecide: [
* { when: (s) => s.errorKind === '5xx-transient' && s.attempt < 3,
* then: 'retry', kind: 'transient-retry' },
* { when: (s) => s.error !== undefined,
* then: 'fail-fast', kind: 'unrecoverable' },
* ],
* circuitBreaker: { failureThreshold: 3 },
* })
* .build();
*
* try {
* await agent.run({ message: 'help' });
* } catch (e) {
* if (e instanceof ReliabilityFailFastError) {
* console.log(e.kind, e.reason);
* }
* }
*/
reliability(config) {
if (this.reliabilityConfig) {
throw new Error('AgentBuilder.reliability: already set. Each agent has at most one reliability config.');
}
this.reliabilityConfig = config;
return this;
}
/**
* Wire a thinking handler (v2.14+). Omitted, the agent uses the handler
* the provider declares (`LLMProvider.thinkingHandler` — `anthropic()`
* declares one, and a wrapper that forwards the field keeps it). Pass a
* handler to override it, or `null` to mount no thinking stage at all.
* Calling twice throws.
*
* @example
* // Custom handler that redacts thinking content
* Agent.create({...}).thinkingHandler(myRedactingHandler).build();
*
* @example
* // Opt out of thinking parsing entirely
* Agent.create({ provider: anthropic({...}), model: '...' })
* .thinkingHandler(null)
* .build();
*/
thinkingHandler(handler) {
if (this.thinkingHandlerValue !== undefined) {
throw new Error('AgentBuilder.thinkingHandler: already set. Each agent has at most one thinking-handler choice.');
}
this.thinkingHandlerValue = handler;
return this;
}
/**
* v2.14+ — REQUEST-side thinking activation. Tells the provider to
* emit reasoning blocks alongside its response.
*
* **What this does:** every LLM call carries
* `LLMRequest.thinking = { budget }` — the intent. The adapter sends the
* shape the MODEL takes, from what it declares per model
* (`LLMProvider.thinkingMode`):
* - `'budget'` (Claude 4.6 and earlier): `thinking: { type: 'enabled',
* budget_tokens: budget }` — a target for the thinking, not a hard cap
* (`max_tokens` is the cap);
* - `'adaptive'` (Claude 4.7 and later, Mythos Preview, and any id the
* table does not know): `thinking: { type: 'adaptive', display:
* 'summarized' }` — the model decides whether and how much to think and
* the budget is NOT sent (dev mode says so once per model); it only
* keeps `max_tokens` above it. An adapter's `thinkingMode` option
* changes the answer for one id (an alias, a profile ARN, or Opus 4.6
* wanted adaptive);
* - `'none'` (Claude 3.x except 3.7 Sonnet): refused here at `build()`
* with an `UnsupportedThinkingError`.
*
* **Distinct from `.thinkingHandler()`:**
* - `.thinking({ budget })` = ASK the model to think (request side)
* - `.thinkingHandler(h)` = NORMALIZE the response (response side)
*
* Most consumers want both; auto-wired handler covers the response
* side automatically when `.thinking()` is set on a thinking-capable
* provider. Setting `.thinking()` without `.thinkingHandler(null)`
* is the typical happy path.
*
* **Provider compatibility:**
* - Anthropic: per model, as above. A budget model needs a whole
* `budget` of at least 1024, and no thinking model takes a
* `temperature` other than the default — both are refused with an
* `UnsupportedThinkingError` before the request is sent.
* - OpenAI: ignores. o1/o3 reasoning is selected at the model id
* level; this field is a no-op for OpenAIProvider.
*
* **`max_tokens`:** thinking counts toward it. When the request's
* `maxTokens` (default 4096 on the Anthropic adapters) is not above the
* budget, the adapter raises it to `budget + 1024`.
*
* Calling twice throws — same shape as `.reliability()` /
* `.outputSchema()`.
*
* @example
* Agent.create({ provider: anthropic({...}), model: 'claude-opus-5-5' })
* .system('You are a careful reasoning agent.')
* .thinking({ budget: 5000 }) // adaptive on Opus 5.5; budget_tokens on Haiku 4.5
* .build();
*/
thinking(opts) {
if (this.thinkingBudgetValue !== undefined) {
throw new Error('AgentBuilder.thinking: already set. Each agent has at most one thinking-budget choice.');
}
if (!Number.isFinite(opts.budget) || opts.budget <= 0) {
throw new Error(`AgentBuilder.thinking: budget must be a positive finite number, got ${String(opts.budget)}.`);
}
this.thinkingBudgetValue = opts.budget;
return this;
}
/**
* Let this agent answer why-questions about its OWN previous completed
* turn, from its recorded trace. Mounts one skill: day to day the tool
* catalog carries only the skill's activation row; when the user asks
* "why did you…", the LLM activates it and that iteration alone gets
* the trace tools (inline mode) or a single `explain_run` tool that
* runs a nested trace debugger on a cheaper model (delegate mode).
*
* Evidence binds LATE — always to the previous COMPLETED run, never
* the in-flight one — and includes control edges (a per-run
* control-dependence recorder is attached automatically).
*
* @example
* Agent.create({ provider, model })
* .system('You are a refunds assistant.')
* .tool(lookupOrder)
* .selfExplain() // inline, zero config
* .build();
*
* @example
* .selfExplain({ delegate: { provider: anthropic(), model: 'claude-haiku-4-5' } })
*/
/**
* Configure the evidence pack that rides a check-in ask. A tool DEMANDS a
* check-in by declaring `checkIn: 'always'` or a `(args, ctx) => boolean`
* predicate ({@link defineTool}); this method controls WHAT the human sees
* when it trips. Optional — a tool with `checkIn` works without it (default:
* `'standard'` evidence + the deterministic lexical scorer, zero LLM calls).
*
* - `evidence: 'standard'` (default) — the full pack: `willDo` (plain-words
* claim), `read` (what context the run consumed), `drivers` (which context
* drove the pick, ranked), `trail` (compact run-so-far summary).
* - `evidence: 'minimal'` — just `willDo` (zero cost).
* - `evidence: <assembler>` — bring your own {@link CheckInAssembler}.
* - `scorer` — swap the `drivers` ranker (default is zero-LLM lexical; pass
* an embedding-backed one wrapping `explainChoice` for semantic ranking).
*
* @example
* Agent.create({ provider, model })
* .tool(defineTool({ name: 'issue_refund', description: 'Refund a charge',
* inputSchema: { type: 'object', properties: { amount: { type: 'number' } } },
* checkIn: (args) => (args.amount as number) > 1000, // ask only for big refunds
* execute: async ({ amount }) => `refunded ${amount}` }))
* .checkIn({ evidence: 'standard' })
* .build();
*/
/**
* Wrap every tool dispatch in a governance chain.
*
* Each middleware answers with one of three verbs — `allow()`, `deny(reason)`
* or `ask({ question })` — and there is deliberately no fourth. In
* particular there is no way to return a result: whatever the chain decides,
* the answer the model finally reads is the real tool's output or a refusal.
* A rule cannot quietly become the tool.
*
* - **`allow()`** passes the call through. **`allow(args, why)`** replaces
* the args and the run commits BOTH versions with your `why` beside them,
* so a slice taken later can find the moment they changed and who changed
* them.
* - **`deny(reason)`** refuses. The reason reaches the model verbatim, as
* the tool result, and the loop continues — the agent adapts in-flight. A
* denial is data, not a crash.
* - **`ask({ question })`** suspends the run for a person, on the same
* checkpoint machinery `checkIn` and `askHuman` use. The answer is a
* DECISION, not a result: approve and the chain resumes and the real tool
* runs; decline and it becomes a denial the model reads.
*
* Order is call order, and each middleware sees the previous one's output.
* The first non-allow answer wins and the rest of the chain does not run. A
* middleware that throws is a denial carrying the error as its reason —
* never a silent pass.
*
* A link may also carry an **`onToolResult`** hook, which decides about the
* RESULT once the tool has run and before the model reads it — `allow()`,
* `allow(value, why)` or `deny(reason)`, and no `ask`, because the tool has
* already run and there is nothing left for a person to prevent. That half
* of the chain is walked BACKWARDS, so the first-declared rule has the first
* word about the call and the last word about the answer. A link with only
* `onToolResult` takes no part in dispatch at all.
*
* `.act({ beforeTool, afterTool })` is the same chain, named by moment.
*
* An existing `PermissionChecker` still decides FIRST: it is not part of
* this chain, it runs ahead of it, so a call it denies never reaches a
* middleware. `gatedTools` is a different layer again — it decides which
* tools the model can SEE; this decides what happens when one is called.
*
* Omit this and nothing changes: no chain walk, no committed ledger key, the
* same request bytes.
*
* @example
* ```ts
* import { Agent, allow, deny } from 'agentfootprint';
*
* const agent = Agent.create({ provider, model })
* .toolMiddleware({
* name: 'no-prod-writes',
* onToolCall: (call) =>
* call.args.env === 'prod' ? deny('writes to prod need a change ticket') : allow(),
* })
* .build();
* ```
*/
toolMiddleware(...middleware) {
for (const mw of middleware)
this.assertToolMiddleware(mw);
this.toolMiddlewareList = [...this.toolMiddlewareList, ...middleware];
return this;
}
/**
* A tool link needs a name and at least one hook. Both are optional
* INDIVIDUALLY — a rule about calls, a rule about results, or both — but a
* link with neither is a governance rule that can never run, and finding
* that out from a quiet run rather than from `build()` is the whole disease.
*/
assertToolMiddleware(mw) {
const shaped = mw !== null &&
typeof mw === 'object' &&
typeof mw.name === 'string' &&
mw.name.length > 0;
const record = mw;
const hooks = shaped &&
(typeof record?.onToolCall === 'function' || typeof record?.onToolResult === 'function');
if (!hooks) {
throw new Error(`AgentBuilder.toolMiddleware: expected an object with a non-empty \`name\` and at ` +
`least one of \`onToolCall(call)\` (decides about the call) or ` +
`\`onToolResult(call)\` ` +
`(decides about the result), got ${typeof mw}. The name is what every ledger row ` +
`and event says decided the call, so it cannot be blank.`);
}
}
/**
* Wrap the message boundary in a governance chain — the input before the
* model sees it, the output before the caller receives it.
*
* Same verbs as {@link toolMiddleware} minus one: there is no `ask` here,
* and the type says so. Tool dispatch runs inside a pausable stage, so it
* has a checkpoint to suspend on; the message boundary is a plain stage, and
* inventing a second pause to give it one would be a worse answer than not
* offering it.
*
* The `'input'` half runs at the very top of the run, BEFORE the message is
* committed. That placement is the point: everything downstream reads
* `scope.history` — the window strategies, the injections, all three slots,
* the bytes on the wire and every slice taken afterwards — so the
* transformed text is what the whole run agrees was said. The `'output'`
* half runs before final capture. Configuring this door withholds provider
* draft tokens and `stream.llm_end.content` (marked `contentWithheld: true`).
* An accepted run emits its captured final answer once; a refused output
* emits no answer or `turn_end` and makes no final memory write. The API
* boundary emits `error.fatal` so public streams can close.
*
* This door may inspect both phases. For input-only rules with immediate
* draft streaming, declare `.act({ input: [rule] })`. Output policy is not
* audit erasure: raw model content and rewrite evidence remain in the run.
* Coverage/standing text is composed after the output rule; policy approval
* is not answer validation.
*
* `deny(reason)` at either phase raises a `MessageDeniedError` rather than
* returning. At `'input'` there is no model to tell; at `'output'` the
* middleware has just refused to release an answer, and handing the caller a
* string in its place is the one substitution they must never make without
* noticing. The error carries the reason, the phase and the middleware's
* name — never the refused content.
*
* @example
* ```ts
* import { Agent, allow } from 'agentfootprint';
*
* const agent = Agent.create({ provider, model })
* .messageMiddleware({
* name: 'mask-card-numbers',
* onMessage: (msg) => {
* const clean = msg.content.replace(/\b(?:\d[ -]?){13,16}\b/g, '[card]');
* return clean === msg.content ? allow() : allow(clean, 'masked a card number');
* },
* })
* .build();
* ```
*/
messageMiddleware(...middleware) {
for (const mw of middleware)
this.assertMiddleware(mw, 'messageMiddleware', 'onMessage');
this.messageMiddlewareList = [...this.messageMiddlewareList, ...middleware];
return this;
}
/** Shared shape check — a chain built from a typo fails at build time, not
* as a silent no-op on the one call that needed governing. */
assertMiddleware(mw, method, hook) {
if (mw === null ||
typeof mw !== 'object' ||
typeof mw.name !== 'string' ||
mw.name.length === 0 ||
typeof mw[hook] !== 'function') {
throw new Error(`AgentBuilder.${method}: expected an object with a non-empty \`name\` and a ` +
`\`${hook}(...)\` method, got ${typeof mw}. The name is what every ledger row and ` +
`event says decided the call, so it cannot be blank.`);
}
}
checkIn(opts = {}) {
if (this.checkInConfig !== undefined) {
throw new Error('AgentBuilder.checkIn: already configured. Call it at most once.');
}
this.checkInConfig = opts;
return this;
}
selfExplain(opts = {}) {
if (this.selfExplainConfig !== undefined) {
throw new Error('AgentBuilder.selfExplain: already enabled.');
}
if (this.opts.reactMode === 'classic') {
// Classic ReAct caches the tools slot on turn 1 — a mid-turn skill
// activation could never surface the trace tools. Fail loud at
// build-time rather than silently never answering why-questions.
throw new Error('AgentBuilder.selfExplain: requires per-iteration slot recomposition — ' +
"reactMode 'classic' caches the tools slot, so the activated trace tools " +
"would never reach the model. Use the default 'dynamic' mode (or " +
"'dynamic-grouped'), or use traceDebugAgent() as a separate session instead.");
}
this.selfExplainConfig = opts;
return this;
}
/**
* Resolve what the loop will enforce about the output — or `undefined` when
* nothing is mounted, which is the default and the byte-identical path.
*
* Both refusals live here rather than in `.outputSchema()` because both
* depend on the WHOLE agent: the tools are registered by other calls that
* may come after, and `.selfExplain()` attaches its own.
*/
/**
* Resolve `.maps()` into the kernel's plan (9.58.0). Undefined when the
* kernel is not mounted — the zero-delta path. Refuses loudly when there
* is nothing to manage: the kernel owns engagement, and with no map
* mounted there is nothing to engage or park.
*/
resolveMapsPlan(injections) {
if (this.mapsOptions === undefined)
return undefined;
if (this.skillGraphNextSkill === undefined) {
throw new Error(`Agent.maps(): nothing is mounted that the kernel could manage — mount a skill ` +
`map first (.skillGraph(defineSkillMap(...).build())). The kernel owns ` +
`ENGAGEMENT (whether a map's prompt and tools ride the next call); with no ` +
`map mounted there is nothing to engage or park.`);
}
if (this.skillGraphNodeIds === undefined || this.skillGraphNodeIds.size === 0) {
throw new Error(`Agent.maps(): the mounted graph carries no node ids (a structurally-typed ` +
`graph). The kernel needs the member list to know exactly which contributions ` +
`parking suppresses — mount the full defineSkillMap(...).build() result.`);
}
// ── A graph that cannot explain its moves (9.59.0) ─────────────────
// The kernel judges an engagement by WHY the cursor is where it is. With
// no `explainNextSkill` no clause ever arrives, so every pass founds
// `assumed` — nobody said why — and the kernel is reduced to a timer over
// a fact it never learns. Before the `assumed` rung existed this was
// worse and silent: an absent clause was written down as `structural`,
// the strongest NON-DECAYING category, so such a graph could never park
// anything at all, with no event and no warning.
//
// Refused rather than warned. Mounting a kernel whose whole job is to
// weigh evidence, onto a map that produces none, is decoration — and the
// refusal is reachable only for a hand-written structurally-typed graph
// (every `skillGraph().build()` supplies the hook), so nothing that works
// today starts failing.
if (this.skillGraphExplainNextSkill === undefined) {
throw new Error(`Agent.maps(): the mounted graph cannot explain its cursor moves — it carries no ` +
`\`explainNextSkill\`. The kernel decides engagement by WHY the cursor is where ` +
`it is (a declared route is system evidence; a keyword match is a guess), so ` +
`without that clause every pass reads "nobody said why" and the kernel is a ` +
`timer over a fact it never learns. Build the map with this version's ` +
`skillGraph() (every graph it builds supplies the clause), or drop .maps().`);
}
const memberIds = [...this.skillGraphNodeIds];
const memberSet = new Set(memberIds);
// The renewal feed's join key: tool NAMES contributed by this map's
// members. Names, not references — the feed joins against the previous
// batch's tool results, which carry names.
const toolNames = [
...new Set(injections
.filter((inj) => memberSet.has(inj.id))
.flatMap((inj) => (inj.inject.tools ?? []).map((t) => t.schema.name))),
];
return {
maps: [
{
id: 'skill-map',
memberIds,
toolNames,
// MANDATORY mount (9.59.0) — documented as shipped since the kernel
// landed and, until now, unreachable through the mount path: the
// kernel's data model carried `nonParkable` per map and no builder
// option ever set it. Value-conditional, so an agent that does not
// ask for it hands the kernel the exact object it always did.
...(this.mapsOptions.nonParkable === true && { nonParkable: true }),
},
],
renewalGrace: this.mapsOptions.renewalGrace ?? 3,
};
}
/**
* Validate a JSON answer against host-owned evidence before delivering it.
* Requires outputSchema. Enforce (default) refuses failed or unverified
* checks; observe returns the answer with a recorded verdict. Both modes
* withhold draft tokens until this boundary. The callback cannot rewrite
* the answer; the schema's JSON-safe output is serialized once for delivery.
* `.outputFallback()` and `.limitsTravelWithTheAnswer()` are refused beside it
* in this version.
*
* `agentChecks: ['claims', 'names-and-numbers']` makes the agent's own
* `.claims()` and `.namesAndNumbersFromEvidence()` verdicts rows of the same
* report, so a contradicted field or an invented value is withheld rather
* than only recorded (see {@link AnswerValidationOptions.agentChecks}).
*/
answerValidation(options) {
if (this.answerValidationConfig !== undefined) {
throw new Error('AgentBuilder.answerValidation: already configured.');
}
this.answerValidationConfig = resolveAnswerValidation(options);
return this;
}
/**
* Observe ordinary prose with `{ prose: true }`, preserving the answer and live
* streaming. Warnings never confirm or block a claim. No schema or tool list is
* required; optional `phrases` extends the grammar with bounded literal data.
* `.outputFallback()` is unsupported because it substitutes text after observation.
*
* Or deliver typed negative claims as library-written statements of the searches
* they cite. `field` is a required top-level array in `.outputSchema()`'s JSON
* Schema; entries contain only `kind`, required `about`, and `restsOn` handles.
* The output adds `statement`, `delivery: 'record-composed'` and `version`.
*
* An unestablished citation withholds the answer by default. `state-searched`
* delivers what was searched while retaining its failed or unverified standing.
* Bounded prose observation files warnings; other answer text remains unchecked.
* The structured path is unmeasured: typed enforcement is available, not recommended
* until its benchmark gates pass. Declaration order with `.outputSchema()` does
* not matter; the complete contract is checked at build.
*
* @example
* ```ts
* builder.negativeClaims({ field: 'negatives', tools: ['list_incidents'] });
* ```
*/
negativeClaims(options) {
if (this.negativeClaimsConfig !== undefined) {
throw new Error('Agent.negativeClaims: already configured.');
}
this.negativeClaimsConfig = resolveNegativeClaims(options);
return this;
}
resolveOutputEnforcement(servedSchema) {
const parser = this.outputSchemaParser;
if (!parser)
return undefined;
let schemaTool;
if (this.outputSchemaStrategy === 'tool-forced') {
// Refusal 1 — the strategy and the agent's tools cannot both be true.
// Forcing the choice BY NAME is what makes the shape a guarantee, and
// it also means no other tool can be called on any turn: a tool-using
// agent would quietly become single-shot. That is config that lies in
// the other direction, so it is refused rather than allowed to happen.
const toolNames = this.registry.map((entry) => entry.name);
const skillCount = this.injectionList.filter((i) => i.flavor === 'skill').length;
const reasons = [];
if (toolNames.length > 0)
reasons.push(`registers ${toolNames.length} tool(s) (${toolNames.join(', ')})`);
if (this.toolProviderRef !== undefined)
reasons.push('has a .toolProvider()');
if (skillCount > 0)
reasons.push(`has ${skillCount} skill(s), which reach the model as tools`);
if (this.selfExplainConfig !== undefined)
reasons.push('has .selfExplain(), which attaches trace tools');
if (reasons.length > 0) {
throw new Error(`AgentBuilder.outputSchema: strategy 'tool-forced' forces every answer through ` +
`the '${SCHEMA_TOOL_NAME}' tool by name, so NO other tool can be called on any ` +
`turn — and this agent ${reasons.join('; ')}. Rather than let those tools go ` +
`silently unusable, two honest paths: drop the tools and keep the forced shape, ` +
`or keep the tools and use { strategy: 'instruct', retries: N } — the corrective ` +
`loop works on every provider and leaves the agent an agent.`);
}
// Refusal 2 — a tool needs its shape, and the library will not invent one.
const jsonSchema = resolveJsonSchema(parser, servedSchema ?? this.outputSchemaJson);
if (jsonSchema === undefined) {
throw new Error(`AgentBuilder.outputSchema: strategy 'tool-forced' puts the schema on the wire as ` +
`a tool, and a tool carries its shape as JSON Schema. This parser does not offer ` +
`one (no \`toJsonSchema()\` method), and a \`parse()\` function is not something ` +
`the library gets to guess a shape from. Pass it: .outputSchema(parser, ` +
`{ strategy: 'tool-forced', jsonSchema: { type: 'object', properties: { … } } }).`);
}
schemaTool = buildSchemaTool(jsonSchema, parser.description);
}
// `'native'` — the provider's structured output. No tools refusal: the
// format constrains the TEXT answer only, so the model may still call a
// tool first and the agent stays an agent. The shape comes from the same
// place, and is refused the same way when there is none; what the API
// cannot express is refused by `nativeOutputFormat`, naming the node.
let outputFormat;
if (this.outputSchemaStrategy === 'native' || this.outputSchemaStrategy === 'auto') {
const jsonSchema = resolveJsonSchema(parser, servedSchema ?? this.outputSchemaJson);
if (jsonSchema === undefined && this.outputSchemaStrategy === 'native') {
throw new Error(`AgentBuilder.outputSchema: strategy 'native' sends the schema to the provider as ` +
`JSON Schema (Anthropic's output_config.format). This parser does not offer one (no ` +
`\`toJsonSchema()\` method), and a \`parse()\` function is not something the library ` +
`gets to guess a shape from. Pass it: .outputSchema(parser, { strategy: 'native', ` +
`jsonSchema: { type: 'object', properties: { … } } }).`);
}
// The answer may carry `_findings` when the ledger is armed — from
// either door, the same test `Agent` arms it by.
const findingsArmed = this.findingsValue !== undefined ||
this.opts.findings !== undefined;
if (jsonSchema !== undefined) {
try {
outputFormat = nativeOutputFormat(jsonSchema, { findings: findingsArmed });
}
catch (error) {
// Auto is best available transport, not a stricter schema contract.
// Known wire-subset refusals retain instruction + local parsing.
// Explicit native and unexpected lowering failures still fail.
if (this.outputSchemaStrategy !== 'auto' || !(error instanceof NativeOutputSchemaError))
throw error;
}
}
}
// Mounted whenever a parser exists (8.18.0). Until then, `retries: 0` —
// the DEFAULT, and what `.outputSchema(parser)` means on its own — returned
// `undefined` here: no judging in the loop, no `outputAttempts` row, no
// event, a chart byte-identical to an agent with no contract at all. The
// declaration bought a prompt sentence and a `runTyped()` parse, and a
// caller on `run()` could not tell from the record that a contract had ever
// been declared, let alone missed.
//
// `0` still mounts no retry BRANCH (see buildAgentChart) and still spends
// no extra turn. It means judge, do not re-ask.
return {
parser,
retries: this.negativeClaimsConfig?.mode !== 'typed'
? this.outputSchemaRetries
: Math.min(this.outputSchemaRetries, 1),
...(schemaTool !== undefined && { schemaTool }),
...(outputFormat !== undefined && {
outputFormat,
...(this.outputSchemaStrategy === 'auto' && { nativeOutputAuto: true }),
}),
hasFallback: this.outputFallbackCfg !== undefined,
...(this.negativeClaimsConfig?.mode === 'typed' && { negativeClaimsEnforced: true }),
...((this.answerValidationConfig?.mode === 'enforce' ||
this.negativeClaimsConfig?.mode === 'typed') && {
answerValidationEnforced: true,
}),
};
}
build() {
// Settings that are only coherent (or not) once the whole agent exists.
this.assertOutputFallbackCoherent();
const negativeClaims = this.negativeClaimsConfig;
let negativeSchema;
if (negativeClaims?.mode === 'prose' && this.outputFallbackCfg !== undefined) {
throw new Error('Agent.negativeClaims({ prose: true }) does not support .outputFallback(): a fallback runs after the observed answer.');
}
if (negativeClaims?.mode === 'typed') {
if (this.outputSchemaParser === undefined) {
throw new Error('Agent.negativeClaims requires .outputSchema(parser, { jsonSchema }).');
}
if (this.outputFallbackCfg !== undefined || this.limitsTravelValue) {
throw new Error('Agent.negativeClaims does not support .outputFallback() or .limitsTravelWithTheAnswer(): the library must own the exact delivered answer.');
}
negativeSchema = negativeClaimsSchema(negativeClaims, resolveJsonSchema(this.outputSchemaParser, this.outputSchemaJson));
}
if (this.answerValidationConfig !== undefined) {
if (this.outputSchemaParser === undefined) {
throw new Error('AgentBuilder.answerValidation requires .outputSchema(parser).');
}
if (this.outputFallbackCfg !== undefined || this.limitsTravelValue) {
throw new Error('AgentBuilder.answerValidation does not support .outputFallback() or .limitsTravelWithTheAnswer(): a fallback can replace the checked answer after validation, and the validated delivery has no place for the declared limits.');
}
// A listed check with nothing to judge is refused, never left to pass.
const listed = this.answerValidationConfig.agentChecks;
if (listed.includes('negative-claims') && negativeClaims?.mode !== 'typed') {
throw new Error("AgentBuilder.answerValidation({ agentChecks: ['negative-claims'] }) requires typed .negativeClaims({ field, tools }).");
}
if (listed.includes('claims') && this.claimsContract === undefined) {
throw new Error("AgentBuilder.answerValidation({ agentChecks: ['claims'] }) needs a .claims() contract: " +
'it names which answer field claims which fact, and without one the claim check has ' +
"nothing to judge. Declare the contract, or drop 'claims' from agentChecks.");
}
if (listed.includes('names-and-numbers') && this.evidenceGate === undefined) {
throw new Error("AgentBuilder.answerValidation({ agentChecks: ['names-and-numbers'] }) needs " +
'.namesAndNumbersFromEvidence(): its verdict is what the row reads. Add the gate, or ' +
"drop 'names-and-numbers' from agentChecks.");
}
}
// The answer layer (honesty layer 4), resolved ONCE from both doors — the
// builder's `.answerLayer()` wins; otherwise `AgentOptions.answerLayer`,
// checked by the same rules — so the refusals below and the arm `Agent`
// receives can never read two different values.
const answerLayer = this.answerLayerValue ?? answerLayerFromOptions(this.opts.answerLayer);
// The answer layer's line appends prose to the answer — refused where the
// answer's bytes are judged or must parse. The standing itself still
// travels as data on both (`turn_end.answerAssessment`).
if (answerLayer?.standingLine !== undefined) {
if (this.answerValidationConfig !== undefined) {
throw new Error('AgentBuilder.answerLayer({ standingLine: true }) cannot be combined with ' +
'.answerValidation(): the answer checks judge the exact bytes delivered, and the line ' +
'would change them. Keep .answerLayer() without the line — the standing still travels ' +
'as data, on turn_end.answerAssessment and agentfootprint.answer.assessed.');
}
if (this.outputSchemaParser !== undefined) {
throw new Error('AgentBuilder.answerLayer({ standingLine: true }) cannot be combined with ' +
'.outputSchema(): a typed answer is JSON, and JSON followed by a line of prose is not. ' +
'Keep .answerLayer() without the line — the standing still travels as data, on ' +
'turn_end.answerAssessment and agentfootprint.answer.assessed.');
}
}
// Resolve the voice config: bundled defaults + consumer overrides.
// Templates flow through the same barrel exports the rest of the
// library uses, so a future locale-pack swap is a single import.
const voice = {
appName: this.appNameValue,
commentaryTemplates: { ...defaultCommentaryTemplates, ...this.commentaryOverrides },
thinkingTemplates: { ...defaultStatusTemplates, ...this.thinkingOverrides },
};
// The SAME options object the builder was given unless a door overrode
// something — `maxIterations`, or the findings ledger (9.101.0), whose
// normalised value lands as `AgentOptions.findings`. An agent with neither
// hands `Agent` its options by reference, exactly as before.
const opts = this.maxIterationsOverride !== undefined ||
this.findingsValue !== undefined ||
this.toolChoiceValue !== undefined ||
this.ontologyValue !== undefined ||
this.inputsLayerValue ||
this.resultsLayerValue ||
answerLayer !== undefined ||
this.timeValue !== undefined
? {
...this.opts,
...(this.maxIterationsOverride !== undefined && {
maxIterations: this.maxIterationsOverride,
}),
...(this.findingsValue !== undefined && { findings: this.findingsValue }),
// Tool choice by classifier (9.105.0), the same door grammar.
...(this.toolChoiceValue !== undefined && { toolChoice: this.toolChoiceValue }),
// The declared ontology (9.106.0), the same door grammar.
...(this.ontologyValue !== undefined && { ontology: this.ontologyValue }),
// The inputs layer (honesty layer 2), the same door grammar — its
// object form only when it also arms declared sources. The door
// MERGES with an `inputsLayer` option given to `Agent.create`: sources
// armed by either stay armed, and a malformed option is still refused
// by the one reader rather than silently replaced by the door's value.
...(this.inputsLayerValue && {
inputsLayer: this.inputsLayerSourcesValue ||
readInputsLayerOption(this.opts.inputsLayer)?.argumentSources === true
? { argumentSources: true }
: true,
}),
// The results layer (honesty layer 3), the same door grammar.
...(this.resultsLayerValue && { resultsLayer: true }),
// The answer layer (honesty layer 4), the same door grammar — the
// value resolved above, whichever door set it.
...(answerLayer !== undefined && {
answerLayer: answerLayer.standingLine === undefined
? true
: answerLayer.standingLine === 'always'
? { standingLine: true }
: { standingLine: { when: answerLayer.standingLine } },
}),
// The time layer, the same door grammar — the builder's value wins
// over an `AgentOptions.time` given to `Agent.create`.
...(this.timeValue !== undefined && { time: this.timeValue }),
}
: this.opts;
// .selfExplain(): a fresh binding per build() — two built agents never
// share evidence. One mounted skill (methodology body only) + the trace
// tools on a skill-scoped ToolProvider composed with the consumer's own,
// so the catalog gains them ONLY on the activated iteration.
//
// Name reservation (mirrors the read_skill rule): the tools slot dedupes
// by name with first-occurrence-wins and the static registry merges
// FIRST — a consumer tool named like a trace tool would silently shadow
// it, and the skill body would instruct the model into the wrong tool.
//
// The pieces come through the port (`selfExplainPort.ts`): the trace
// toolpack sits above the agent, so it registers itself and this line
// only asks — refusing with one sentence when nothing registered.
const selfExplain = this.selfExplainConfig ? requireSelfExplain() : undefined;
if (this.selfExplainConfig) {
// Inline mode reserves whatever the pack can mount — read from
// `TRACE_TOOL_NAMES` rather than retyped here, so a tool added to the
// pack is reserved the same day it ships. Delegate mode mounts one
// tool and reserves one name.
const reserved = this.selfExplainConfig.delegate
? ['explain_run']
: TRACE_TOOL_NAMES;
const clash = this.registry.find((entry) => reserved.includes(entry.name));
if (clash) {
throw new Error(`AgentBuilder.selfExplain: tool name '${clash.name}' is reserved by .selfExplain() ` +
`(${this.selfExplainConfig.delegate ? 'delegate' : 'inline'} mode). The tools slot ` +
`dedupes by name (first wins), so your tool would silently shadow the trace tool. ` +
`Rename it, or reserved names: ${reserved.join(', ')}.`);
}
}
// THE CONTINGENT LINE (9.110.0) — the findings instruction gains one
// line when the evidence gate is armed beside the ledger, and only then:
// `findings/contingent.ts` runs under both doors, so the sentence that
// names what it records is registered under both doors. Composed HERE
// because the two doors may be called in either order; the injection
// registered by `.findings()` is rebuilt in place, same id, same
// activation, so an agent with the ledger alone keeps the exact bytes
// `.findings()` registered.
// Declared sources (honesty layer 2) add NO line: `from` is explained
// once, in its own property on the ruled tools that carry it.
if (this.findingsValue !== undefined && this.evidenceGate !== undefined) {
const at = this.injectionList.findIndex((i) => i.id === FINDINGS_INSTRUCTION_ID);
if (at >= 0) {
this.injectionList[at] = findingsInstruction(true, this.findingsValue.answerAsk === 'quote-facts');
}
}
const selfExplainBinding = selfExplain && this.selfExplainConfig
? selfExplain.binding(this.selfExplainConfig)
: undefined;
let injections = selfExplain && selfExplainBinding
? [...this.injectionList, selfExplain.skill(this.selfExplainConfig)]
: this.injectionList;
if (negativeClaims?.mode === 'typed' && negativeSchema !== undefined) {
injections = [
...injections,
defineInstruction({
id: 'negative-claims',
activeWhen: () => true,
prompt: negativeClaimsInstruction(negativeClaims, negativeSchema),
}),
];
}
// The tier-3 menu envelope's system-prompt half (SG-C): auto-registered
// whenever the mounted graph can produce a MENU verdict — a menu with no
// envelope would be a decision the model was never told about, the
// accepted-and-silently-wrong kind. Not under `'rails'` (the model may
// not act on a menu there), and not when the consumer registered their
// own (the metadata marker, never the id — a renamed hint still counts).
// Zero cost when the trigger is false: it reads `ctx.turnRoute`, which
// only cascade graphs ever write.
const cascadeForHint = this.skillGraphCascade;
if (cascadeForHint !== undefined &&
cascadeForHint.strictness !== 'rails' &&
cascadeForHint.turnRouting !== undefined &&
(cascadeForHint.turnRouting.scorer !== undefined ||
cascadeForHint.continuity === 'conversation') &&
!injections.some((i) => i.metadata?.[MENU_HINT_METADATA_KEY])) {
injections = [...injections, defineMenuHint()];
}
// ── The tool posture (9.36.0) — `.toolsFromActiveSkill()` ──────────
// Applied to the FINAL injection list, for the reason every fold on this
// line uses: skills arrive through `.skill()`, `.skills()`, `.injection()`,
// `.skillGraph()` and `.selfExplain()`, and only here is the whole agent
// visible. From this point on there is exactly ONE list, so the registry,
// the slots, the projection and every recorder read the same stamp.
// Untouched when the posture was never asked for — the `false` branch does
// not even walk the list.
if (this.toolsFromActiveSkillValue) {
injections = scopeToolsToActiveSkill(injections);
}
// ── Steps-as-data check-up + advisory (9.18.0) ─────────────────────
// Judged on the FINAL injection list (the delivery-refusal reasoning:
// skills arrive through `.skill()`, `.skillGraph()`, `.skills()` and
// `.selfExplain()`, and only this line sees all of them). Nothing here
// runs for an agent without a stepped skill.
const stepPlans = foldStepPlans(injections);
if (stepPlans.size > 0) {
// Classic caches the tools slot after turn 1, so the per-step
// narrowing and the banner would FREEZE at whatever step was current —
// sequence enforcement that stops tracking the sequence. Refused, the
// classic+graph sentence pattern (9.16.0).
if (this.opts.reactMode === 'classic') {
const ids = [...stepPlans.keys()].join("', '");
throw new Error(`Agent.build(): reactMode 'classic' cannot honor \`steps\` (declared by '${ids}'). ` +
`Classic composes the tools slot once on turn 1, so the per-step narrowing and ` +
`the step banner would freeze at the first step while the procedure moved on — ` +
`the offer would enforce a sequence it stopped tracking. Use the default ` +
`'dynamic' mode (or 'dynamic-grouped'), which recomposes the slots every ` +
`iteration, or drop \`steps\`.`);
}
// A decision .tree() never writes a cursor (per-iteration predicate
// routing — skillGraph's resolver stays the no-op there), and a step
// procedure's tenure begins only under the cursor. A stepped tree
// LEAF would slip past the wiring check below — its compiled trigger
// is 'rule' and it has a predicate edge, so the open-skill clause
// never fires — and then activate with its FULL toolset: no pointer,
// no narrowing, no banner, no skip_step, no step event, nothing
// saying why (accepted-and-silently-wrong, the cardinal sin). Same
// fate for a stepped skill registered BESIDE the tree: no cursor
// exists anywhere on a tree agent. Refused for all of them, the
// continuity×tree sentence pattern (9.17.0).
if (this.skillGraphIsTree) {
const ids = [...stepPlans.keys()].join("', '");
throw new Error(`Agent.build(): a decision .tree() cannot honor \`steps\` (declared by '${ids}'). ` +
`A tree routes by predicate on every iteration and never writes a cursor, and ` +
`a step procedure's tenure begins only under the cursor — the steps would ` +
`never engage: no pointer, no narrowing, no banner, no step event. Use the ` +
`flat entry/route form, register the skill on an agent without a graph, or ` +
`drop \`steps\`.`);
}
const edgeTargets = new Set(this.skillGraphEdgeTargets ?? []);
for (const [skillId] of stepPlans) {
const skill = injections.find((i) => i.id === skillId);
if (!skill)
continue;
if (this.skillGraphNextSkill !== undefined) {
// A graph is mounted: the tenant is the CURSOR, and the cursor
// never lands on an OPEN skill (llm-activated + no incoming edge —
// `Agent.openSkillIds`'s two clauses). Steps on one would activate
// a body whose procedure never engages: no pointer, no narrowing,
// no banner, no event, nothing saying why. Refused instead
// (accepted-and-silently-wrong is the cardinal sin).
const isOpen = skill.trigger.kind === 'llm-activated' && !edgeTargets.has(skillId);
if (isOpen) {
throw new Error(`Agent.build(): skill '${skillId}' declares \`steps\`, but the mounted skill ` +
`graph does not wire it (no entry, no route edge) — it is an OPEN skill, ` +
`activated by read_skill without ever receiving the cursor, and a step ` +
`procedure's tenure begins only under the cursor. Its steps would never ` +
`engage. Wire it into the graph (an entry or a route), register it on an ` +
`agent without a graph, or drop \`steps\`.`);
}
}
else if (skill.trigger.kind !== 'llm-activated') {
// No graph: the tenant is the most recent read_skill activation
// (the tail of activatedInjectionIds), and ONLY read_skill writes
// that array — a rule/always-triggered skill would never tenure.
throw new Error(`Agent.build(): skill '${skillId}' declares \`steps\`, but its trigger is ` +
`'${skill.trigger.kind}' and this agent mounts no skill graph — without a ` +
`cursor, a procedure's tenure begins at a read_skill activation, which only ` +
`an 'llm-activated' skill ever gets. Use defineSkill (its trigger is ` +
`llm-activated), mount a graph that routes to it, or drop \`steps\`.`);
}
}
// Warn about an iteration budget too small for the declared procedure.
if (isDevMode()) {
const effectiveMax = opts.maxIterations ?? 10;
for (const plan of stepPlans.values()) {
// Warn, not refuse: adjacent same-tool steps can advance twice in
// one batch, so a tight budget CAN still complete a procedure.
if (plan.steps.length + 1 > effectiveMax) {
// eslint-disable-next-line no-console
console.warn(`agentfootprint Agent: skill '${plan.skillId}' declares ${plan.steps.length} ` +
`steps but maxIterations is ${effectiveMax} — the procedure cannot complete ` +
`in one turn unless steps share a batch. Raise maxIterations, or expect ` +
`steps_unfinished { action: 'cut-short' } on the record.`);
}
}
}
// The steps advisory (the menu-hint pattern, one block up): a narrowed
// offer with no explanation would be sequence enforcement the model was
// never told about. Marker-detected, so a consumer's own hint (any id)
// stands the default down.
if (!injections.some((i) => i.metadata?.[STEPS_HINT_METADATA_KEY])) {
injections = [...injections, defineStepsHint()];
}
}
const toolProvider = selfExplain && selfExplainBinding
? selfExplain.toolProvider(selfExplainBinding, this.selfExplainConfig, this.toolProviderRef)
: this.toolProviderRef;
// A skill may claim the `read_skill` delivery channel only if `read_skill` is
// what activates it — otherwise its body reaches the model through no channel at
// all (8.5.0). Checked HERE, on the final list, for the same reason
// `resolveOutputEnforcement` lives here: the answer depends on the WHOLE agent.
// `.skillGraph()` compiles the triggers, `.skill()` may add more after it, and
// `.selfExplain()` adds one right above — only this line sees all of them.
const deliveryRefusal = toolOnlyDeliveryRefusal(injections);
if (deliveryRefusal)
throw new Error(deliveryRefusal);
// Two governance refusals that need the WHOLE agent (8.13.0), for the same
// reason `resolveOutputEnforcement` and the delivery refusal live here:
// observers arrive across many `.watch()` calls, and a `checkIn` demand can
// come from `.tool()` OR from a skill's own tools, which only this line sees
// assembled.
assertNoCollidingObserverIds(this.recorderList);
assertCheckInHasADeclaringTool(this.checkInConfig !== undefined, this.registry, injections, toolProvider);
// Two dev-mode warnings about wiring that is inert rather than wrong (8.7.0).
// Both need the WHOLE agent — one asks whether a scorer exists, the other pairs a
// provider against an injection — so both live here, beside the refusal above.
warnInertRelevanceHint(injections, this.skillGraphScoreEntries !== undefined);
warnRedundantSkillScopedTools(this.toolProviderRef, injections);
// The skill graph's DEFERRED body-contract checks run HERE — the one build point
// that can see the full tool registry. At graph build (without `knownTools`) a
// body's `lookup_order(id)` is indistinguishable from a typo, because `.tool()`
// has not run yet; the graph said nothing it could not prove and left its note.
// The note is collected from the FINAL injection list (each deferred graph skill
// carries it in its metadata), so the checks run whichever door the skills came
// through — `.skillGraph(graph)` or `.skills({ list: () => graph.skills })`.
// The `.skillGraph()` capture is folded in as a fallback for a structurally-typed
// graph without per-skill stamps; skills found by both are deduped by id, so one
// problem is reported once. The known set checked now: the agent's registered
// tool names ∪ `read_skill` (auto-attached whenever a skill exists, so a hand-off
// hint naming it is not a typo) ∪ every NON-deferred skill's tool names (they
// exist on this agent; calling them nonexistent would be false) — plus, inside
// checkSkillContracts, the checked skills' own tools. Runs at most once per built
// agent, and never when the graph already ran its checks with an explicit
// `knownTools`: one problem, one report. Baseline `ToolProvider` tools are
// per-iteration and cannot be enumerated at build time — bodies naming only
// provider-delivered tools should pass the names via the graph's `knownTools`.
const deferredByMode = new Map();
const deferredIds = new Set();
const collectDeferred = (skill, mode) => {
if (deferredIds.has(skill.id))
return; // metadata + graph note = ONE check
deferredIds.add(skill.id);
const group = deferredByMode.get(mode);
if (group)
group.push(skill);
else
deferredByMode.set(mode, [skill]);
};
for (const i of injections) {
const note = i.metadata?.[SKILL_GRAPH_DEFERRED_CONTRACT_KEY];
if (note?.mode === 'throw' || note?.mode === 'warn')
collectDeferred(i, note.mode);
}
if (this.skillGraphDeferredBodyContract) {
const { mode, skills: graphSkills } = this.skillGraphDeferredBodyContract;
for (const s of graphSkills)
collectDeferred(s, mode);
}
if (deferredByMode.size > 0) {
// Tools on skills OUTSIDE the deferred set exist on this agent — baseline,
// not typos. Tools on deferred skills are added per group by
// checkSkillContracts itself (own vs known matters for body-foreign-tool).
const baseKnownTools = [
...this.registry.map((entry) => entry.name),
'read_skill',
...injections
.filter((i) => i.flavor === 'skill' && !deferredIds.has(i.id))
.flatMap((s) => skillToolNames(s)),
];
// One group per deferring graph's `check` mode — two graphs fed via
// `.skills()` may disagree, and each is judged by its OWN declared severity.
for (const [mode, group] of deferredByMode) {
const groupIds = new Set(group.map((s) => s.id));
const knownTools = [
...baseKnownTools,
...[...deferredByMode.values()]
.flat()
.filter((s) => !groupIds.has(s.id))
.flatMap((s) => skillToolNames(s)),
];
const problems = checkSkillContracts(group, { knownTools });
if (problems.length === 0)
continue;
const report = { ok: !problems.some((p) => p.kind === 'error'), problems };
// Severity per the GRAPH's own check mode. Both contract checks are
// warnings today, so `'throw'` and `'warn'` both surface as a dev-mode
// warning — but an error-kind problem, should one ever exist, honors
// `'throw'` here exactly as it would have at graph build.
if (mode === 'throw' && !report.ok) {
throw new Error(`Agent.build(): skill-body ↔ tool-contract check-up failed ` +
`(deferred from graph build; checked against this agent's full tool ` +
`registry):\n${formatCheckup(report)}`);
}
if (isDevMode()) {
// eslint-disable-next-line no-console
console.warn(`agentfootprint Agent: skill-graph body-contract check-up (deferred from ` +
`graph build; checked here against the agent's full tool registry — pass ` +
`knownTools to skillGraph to run it at graph build instead):\n` +
formatCheckup(report));
}
}
}
// ── The artifact vocabularies (SG-F, 9.25.0) ──────────────────────
// Runs on the FINAL injection list, for the reason every check on this
// line does: skills arrive through `.skill()`, `.skills()` AND
// `.skillGraph()`, and only here is the whole agent visible — which is
// exactly what the satisfiability rule needs ("does ANYTHING on this
// agent declare it produces this kind?"). A graph that already ran its
// own checkup re-reports here only if a producer outside the graph was
// what silenced it, which is the answer the author wants either way.
//
// Always a dev-mode WARNING, never a throw, on ANY check mode: the rule
// reads declarations only and cannot see a store seeded by another agent
// or an earlier run (`skillVocabulary.ts` states the whole boundary), so
// failing a build on it would be claiming more than it knows.
const vocabularyProblems = checkArtifactVocabularies(injections);
if (vocabularyProblems.length > 0 && isDevMode()) {
// eslint-disable-next-line no-console
console.warn(`agentfootprint Agent: artifact-vocabulary check-up:\n` +
formatCheckup({ ok: true, problems: vocabularyProblems }));
}
// ── The brains fold (9.19.0) — both declaration homes, one check-up ──
// Runs on the FINAL injection list for the same reason the delivery
// refusal does: `defineSkill({ provider })` can arrive through
// `.skill()`, `.skills()` or `.skillGraph()`, and only this line sees
// all of them. Undefined — nothing declared anywhere — and the Agent
// wires nothing new.
const skillBrains = foldSkillBrains({
injections,
...(this.skillGraphBrainOptions?.providers !== undefined && {
providers: this.skillGraphBrainOptions.providers,
}),
...(this.skillGraphBrainOptions?.escalation !== undefined && {
escalation: this.skillGraphBrainOptions.escalation,
}),
...(this.skillGraphBrainOptions?.decider !== undefined && {
decider: this.skillGraphBrainOptions.decider,
}),
graphMounted: this.skillGraphNextSkill !== undefined,
...(this.skillGraphNodeIds !== undefined && { nodeIds: this.skillGraphNodeIds }),
agentProviderName: opts.provider.name,
});
// ── Thinking, against what each provider DECLARES for its model ──
// After the brains fold, because a brain's model is a model the thinking
// request reaches too. A model that cannot think is refused HERE, by name
// (`thinkingSupport.ts`), not as a vendor 400 on the first call.
if (this.thinkingBudgetValue !== undefined) {
checkThinkingSupport({
budget: this.thinkingBudgetValue,
provider: opts.provider,
model: opts.model,
...(skillBrains !== undefined && { brains: skillBrains }),
...(this.reliabilityConfig?.providers !== undefined && {
reliabilityProviders: this.reliabilityConfig.providers,
}),
});
}
// A decider with no menu to resolve: only a graph that RUNS the
// turn-start cascade (a classifier, or `continuity: 'conversation'`)
// ever produces an outstanding menu, so a decider on any other mount
// would be config that never runs — refused rather than silently inert.
if (skillBrains?.decider !== undefined) {
const cascade = this.skillGraphCascade;
const runsCascade = cascade !== undefined &&
cascade.turnRouting !== undefined &&
(cascade.turnRouting.scorer !== undefined || cascade.continuity === 'conversation');
if (!runsCascade) {
throw new Error(`Agent.build(): a routing decider is declared, but this mount never runs the ` +
`turn-start cascade — no menu can ever be outstanding, so the decider would ` +
`never be consulted. Give the graph a classifier (.classify(...) / start rules ` +
`with intents) or mount with continuity: 'conversation', or drop \`decider\`.`);
}
}
// The maps kernel's plan (9.58.0) — resolved HERE because it needs both
// the mounted graph (member ids) and the final injection list (each
// member's contributed tool names). Refusals live in the resolver.
const mapsPlan = this.resolveMapsPlan(injections);
// The script record (`.scriptTravelsWithTheAnswer()`, `code: 'match'`) needs
// a tool that DECLARES it runs a program — never inferred from a name.
const declaresScript = [
...this.registry.map((entry) => entry.tool),
...injections.flatMap((injection) => injection.inject?.tools ?? []),
].some((tool) => scriptOf(tool) !== undefined);
if (this.scriptTravelValue !== undefined &&
!declaresScript &&
this.toolProviderRef === undefined) {
throw new Error('AgentBuilder.scriptTravelsWithTheAnswer: no registered tool declares `script`, so no ' +
'call of this agent can ever file a program to attach. Register a code-running tool ' +
"that declares one (`codeRunnerTool` does; a hand-built tool declares `script: { argument: 'code' }`), " +
'or drop this option.');
}
if (this.evidenceGate?.code === 'match' &&
!declaresScript &&
this.toolProviderRef === undefined &&
isDevMode()) {
// eslint-disable-next-line no-console
console.warn("agentfootprint: .namesAndNumbersFromEvidence({ code: 'match' }) on an agent where no " +
'registered tool declares `script` — every fenced block the model shows is then a miss, ' +
'read as prose. Register a code-running tool that declares `script` (codeRunnerTool ' +
"does), or drop `code: 'match'`.");
}
// The claim contract (9.61.0) — refused here rather than at `.claims()`,
// because `.outputSchema()` may legitimately be declared after it.
// A contract with no typed stratum to read would check nothing, and a
// check that silently checks nothing is the decay this family exists
// to prevent.
if (this.claimsContract !== undefined && this.outputSchemaParser === undefined) {
throw new Error(`Agent.claims(): a claim contract needs a validated answer to read — add ` +
`.outputSchema(schema). The claim seam compares TYPED fields; prose is never ` +
`checked, so without a schema this contract would check nothing.`);
}
const claimContract = this.claimsContract === undefined
? undefined
: Object.entries(this.claimsContract).map(([answerField, fact]) => ({
answerField,
entity: fact.entity,
field: fact.field,
}));
if (negativeClaims?.mode === 'typed') {
assertNegativeClaimsTools(negativeClaims, [
...this.registry.map((entry) => entry.tool),
...injections.flatMap((injection) => injection.inject?.tools ?? []),
]);
}
const agent = new Agent(opts, this.systemPromptValue, this.registry, voice, injections, this.memoryList, this.outputSchemaParser, toolProvider, this.systemPromptCachePolicy, this.cachingDisabledValue, this.cacheStrategyOverride, this.outputFallbackCfg, this.reliabilityConfig, this.thinkingHandlerValue, this.thinkingBudgetValue, this.skillGraphNextSkill, this.skillGraphReachable, this.skillGraphScoreEntries, this.checkInConfig, this.runConfigFn, this.windowStrategyValue, this.toolMiddlewareList, this.messageMiddlewareList, this.resolveOutputEnforcement(negativeSchema), this.skillGraphEdgeTargets, this.skillGraphExplainNextSkill, this.skillGraphIsTree, this.skillGraphSupersededEntries, this.skillGraphCascade, skillBrains, this.evidenceGate, this.limitsTravelValue,
// Declaration order, and a COPY: the manifest reports what this build
// applied, and a later `.recipe()` on the same builder (a second
// `build()`) must not retroactively edit the agent already handed out.
// Undefined rather than `[]` when none — the manifest's "absent means not
// configured" law, and what keeps an agent with no recipes byte-identical.
this.appliedRecipeList.length > 0 ? [...this.appliedRecipeList] : undefined, this.skillGraphDeclared, mapsPlan, claimContract, this.answerValidationConfig, this.systemPromptCacheExplicit, negativeClaims, this.scriptTravelValue);
// Attach the observers collected by `.watch()` so they receive events
// from the very first run. Mirrors what consumers would do post-build
// via `agent.attach(rec)`; the builder method is purely sugar over it,
// which is what makes `.watch()` provably the same attachment.
for (const observer of this.recorderList) {
agent.attach(observer);
}
if (selfExplainBinding) {
// Late binding: capture fires at each run's terminal flush, when
// getLastSnapshot() IS the just-completed run (never in-flight).
//
// All THREE sources in one call, because a turn's evidence is three
// things that a snapshot alone does not carry: what happened
// (snapshot), how it read in English (narrative), and when each tool
// call started and ended (events — the commit log has no clock).
//
// …plus a FOURTH when any mounted tool keeps a record of its own run
// (`flowchartAsTool({ keepRecord: true })`). Collected here rather
// than resolved per call because the store is a LIVE object owned by
// the tool: the binding holds the lookup, and every record the tool
// files after this point is visible through it.
const innerRuns = collectInnerRuns(this.registry, injections);
selfExplainBinding.bindTo({
getSnapshot: () => agent.getLastSnapshot(),
getNarrative: () => agent.getLastNarrativeEntries(),
// Only the explained run's own events (`Agent.ownsEvent`): on an
// instance serving several sessions the hosting door emits facts for
// OTHER sessions while this run is in flight.
on: (type, listener) => agent.on(type, (event) => {
if (agent.ownsEvent(event))
listener(event);
}),
...(innerRuns !== undefined && { getInnerRuns: () => innerRuns }),
// …and the evidence as a whole is kept PER CONVERSATION (R2-11): on a
// shared `standingAgent({ agent })` the previous completed run of the
// INSTANCE is whoever ran last, so the binding files each finished
// turn under its conversation and serves the asking run's only —
// snapshot, narrative, events and the tools' inner runs alike
// (`agent/servingConversation.ts` says what the key is).
getServing: () => servingConversationOf(agent),
});
agent.attach(selfExplainBinding.recorder());
// …and the agent holds the binding, so `agent.canExplain()` answers
// from the SAME fact the trace tools answer from rather than from a
// second guess about whether a turn has completed.
agent.bindSelfExplain(selfExplainBinding);
}
return agent;
}
}
/**
* The line's modes, as the option doors check them — keyed by the type the
* layer's composer decides with (`assessment/compose.ts` · `StandingLineWhen`),
* so a mode added there does not compile until it is listed here. A type-only
* import: the composer stays off a plain agent's module graph.
*/
const STANDING_LINE_MODES = Object.freeze({
always: true,
'unless-consistent': true,
'not-sure-or-ask': true,
});
/**
* THE ANSWER LAYER'S ARM (honesty layer 4), checked by ONE rule for both of
* its doors — `.answerLayer(options)` and `AgentOptions.answerLayer` — so a
* value one door refuses, the other refuses too: an unknown key, a
* `standingLine` that is neither a boolean nor `{ when }`, or a `when` outside
* the three modes, is refused by name. Returns the arm normalised — the line's
* mode (`true` → `'always'`), or `{}` for no line.
*/
function answerLayerArm(options, door) {
const unknown = Object.keys(options).filter((k) => k !== 'standingLine');
if (unknown.length > 0) {
throw new Error(`${door}: unknown option(s) ${unknown.map((k) => `'${k}'`).join(', ')}. ` +
"The one option is standingLine (boolean, or { when: 'always' | 'unless-consistent' | " +
"'not-sure-or-ask' }).");
}
const line = options.standingLine;
if (line === undefined || line === false)
return {};
if (line === true)
return { standingLine: 'always' };
const modes = Object.keys(STANDING_LINE_MODES)
.map((w) => `'${w}'`)
.join(', ');
if (typeof line !== 'object' || line === null || Array.isArray(line)) {
throw new Error(`${door}: standingLine must be a boolean or { when } — when is one of ${modes}.`);
}
const extra = Object.keys(line).filter((k) => k !== 'when');
if (extra.length > 0) {
throw new Error(`${door}: standingLine has unknown option(s) ${extra.map((k) => `'${k}'`).join(', ')}. ` +
`The one option is when: ${modes}.`);
}
const when = line.when;
if (typeof when !== 'string' ||
!Object.prototype.hasOwnProperty.call(STANDING_LINE_MODES, when)) {
throw new Error(`${door}: standingLine.when must be one of ${modes} (got ${JSON.stringify(when) ?? 'nothing'}).`);
}
return { standingLine: when };
}
/**
* `AgentOptions.answerLayer` as the arm `build()` resolves: `undefined` when
* it is absent or `false`, `{}` for `true`, an object through
* `answerLayerArm` — so the options door meets every refusal the builder's
* door does. Anything else is refused by name.
*/
function answerLayerFromOptions(value) {
if (value === undefined || value === false)
return undefined;
if (value === true)
return {};
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('AgentOptions.answerLayer must be a boolean or an object — ' +
"{ standingLine?: boolean | { when: 'always' | 'unless-consistent' | 'not-sure-or-ask' } }.");
}
return answerLayerArm(value, 'AgentOptions.answerLayer');
}
/**
* Refuse two DIFFERENT observers that share one id (8.13.0).
*
* footprintjs de-duplicates attached recorders by id — `attachScopeRecorder` and
* `attachFlowRecorder` both `filter(r => r.id !== recorder.id)` before pushing —
* so of two objects carrying one id, only the LAST ever fires. The first is
* removed before the run starts and reports nothing at all, which is
* indistinguishable from an observer whose events simply never happened.
*
* Keyed on OBJECT IDENTITY, not on the id alone. Handing the same reference to
* `.watch()` twice is
* harmless and stays one attachment — the fp dedupe is doing exactly what it is
* for. It is two DIFFERENT observers under one name that loses a whole observer.
*
* Deliberately does NOT reserve the `agentfootprint.` id prefix: the factories in
* `agentfootprint/observe` carry ids in that namespace and consumers are meant to
* `.watch()` them.
*/
function assertNoCollidingObserverIds(observers) {
const byId = new Map();
for (const observer of observers) {
const id = observer.id;
const seen = byId.get(id);
if (seen === undefined) {
byId.set(id, observer);
continue;
}
if (seen === observer)
continue; // same reference — one attachment, by design
throw new Error(`AgentBuilder.watch: two different observers were given the id '${id}'. Only the LAST ` +
`one ever fires — footprintjs de-duplicates attached recorders by id, so the earlier ` +
`one is dropped before the first run and reports nothing. Rename one of them; passing ` +
`the SAME object twice is fine (it stays one attachment).`);
}
}
/**
* Refuse `.checkIn({...})` on an agent where no tool can ever trip the gate (8.13.0).
*
* `.checkIn()` configures HOW the ask is assembled — the evidence preset, the
* drivers scorer. What MAKES an ask is a tool declaring `checkIn`. Resolution
* already defaults (standard evidence + the lexical scorer), so a declaring tool
* works with no `.checkIn()` call at all; the reverse — `.checkIn()` with nothing
* that declares — configures the shape of a question that is never asked.
*
* Scans BOTH sources the dispatch map is built from (`buildToolRegistry`): the
* `.tool()` registry and every skill's `inject.tools`, since a skill tool with a
* `checkIn` demand is a real gate.
*
* NOT refused when a `.toolProvider()` is wired. Its tools are resolved per
* iteration and may declare `checkIn`; build time cannot know, and refusing what
* it cannot know would break a correct agent.
*/
function assertCheckInHasADeclaringTool(configured, registry, injections, toolProvider) {
if (!configured || toolProvider !== undefined)
return;
const declares = (tool) => tool.checkIn !== undefined;
if (registry.some((entry) => declares(entry.tool)))
return;
for (const injection of injections) {
for (const tool of injection.inject?.tools ?? []) {
if (declares(tool))
return;
}
}
throw new Error('AgentBuilder.checkIn: configured, but no registered tool declares `checkIn` — so the gate ' +
'is never consulted, this agent will never pause for consent, and the evidence settings ' +
'here decide nothing. `.checkIn()` configures HOW the ask is assembled; a tool declaring ' +
"`checkIn: 'always'` (or a predicate) is what MAKES the ask. Add it to the tool that " +
"needs consent — defineTool({ …, checkIn: 'always' }) — or drop `.checkIn()`.");
}
/**
* Gather the inner-run stores of every tool that keeps one (8.17.0).
*
* A `flowchartAsTool({ keepRecord: true })` carries its store on the tool
* object under a registry symbol. This finds them so `.selfExplain()` can
* hand one merged lookup to the trace artifacts — the consumer wires
* nothing; mounting the tool and calling `.selfExplain()` is the whole
* configuration.
*
* Two sources, both known at build time: the static registry (`.tool()` /
* `.tools()`) and skill-declared tools. A tool arriving from a
* `.toolProvider()` is deliberately NOT collected — provider tools are
* resolved per iteration against a live context, so there is no build-time
* moment at which the list exists. `inspect_tool_run` then answers with the
* honest-absence arm, which names `keepRecord`; a consumer in that position
* should register the chart tool statically as well.
*/
function collectInnerRuns(registry, injections) {
const found = [];
const take = (candidate) => {
const store = innerRunsOf(candidate);
if (store !== undefined && !found.includes(store))
found.push(store);
};
for (const entry of registry)
take(entry.tool);
for (const injection of injections) {
for (const tool of injection.inject?.tools ?? [])
take(tool);
}
return mergeInnerRuns(found);
}
/**
* Refuse an `llm-activated` trigger whose `viaToolName` is not `'read_skill'` (8.7.0).
*
* `viaToolName` reads as a promise the library never kept: name a tool, and that tool
* activates the skill. No such tool has ever been created. The one consumer of an
* `llm-activated` trigger is the evaluator, which matches on
* `ctx.activatedInjectionIds.includes(inj.id)` and never looks at the field
* (`evaluator.ts`); the only writer of that array is `read_skill`. So a skill carrying
* `viaToolName: 'open_playbook'` activated by `read_skill` exactly like every other
* skill, and the declaration meant nothing at all — a configuration that was never
* read is worse than one that is refused, because it looks like it worked.
*
* Refused HERE because `injection()` is the one funnel every flavor passes through:
* `defineSkill`, `skillsFromDir`, `.skill()`, `.skills(registry)`, `.skillGraph()`
* and a hand-built Injection all arrive at this line.
*
* 9.0.0 removed the `viaToolName` OPTION from `defineSkill` and `skillsFromDir`
* (both now refuse it by name at the factory), but the TRIGGER field stays and so
* does this check: a hand-built `Injection` literal can still carry an
* `llm-activated` trigger with any string in it, and that is the one path with no
* factory in front of it.
*/
function assertReadSkillActivation(injection) {
const trigger = injection.trigger;
if (trigger.kind !== 'llm-activated')
return;
if (trigger.viaToolName === 'read_skill')
return;
throw new Error(`Agent.injection('${injection.id}'): viaToolName is '${trigger.viaToolName}', but ` +
`'read_skill' is the only activation tool this library builds — nothing reads the field, ` +
`and no tool named '${trigger.viaToolName}' is ever offered to the model. This skill would ` +
`have activated through read_skill like every other one, so the name was decoration on a ` +
`door that does not exist. Drop \`viaToolName\` (skills already share one activation tool ` +
`and the model picks WHICH skill by id), or gate the skill on something the engine does ` +
`read — a \`rule\` trigger, or a skillGraph() edge. The \`viaToolName\` OPTION on ` +
`defineSkill()/skillsFromDir() was removed in 9.0.0; this trigger field is all that ` +
`is left, and only a hand-built Injection can still set it.`);
}
/**
* Dev-warn a `defineRelevanceHint()` mounted where no scorer can feed it (8.7.0).
*
* The hint's trigger reads `ctx.entryScores`, and only the PickEntry stage writes it
* — which runs only when the graph was built with `.entryBy()` / `.entryByRelevance()`
* / `start: { entries, scoredBy | byRelevance }`. Without one, the injection mounts,
* evaluates false on every iteration for the life of the agent, and reports nothing:
* a feature that is configured and inert looks exactly like a feature that is working
* and simply has not been needed yet.
*
* Keyed on the `readsEntryScores` metadata marker, not on the id — the id is the
* caller's to rename.
*/
function warnInertRelevanceHint(injections, hasScorer) {
if (hasScorer || !isDevMode())
return;
for (const inj of injections) {
const meta = inj.metadata;
if (meta?.[READS_ENTRY_SCORES_METADATA_KEY] !== true)
continue;
// eslint-disable-next-line no-console
console.warn(`agentfootprint Agent: injection '${inj.id}' reads ctx.entryScores, which only an ENTRY ` +
`SCORER produces — and this agent's skill graph has none, so the injection can never ` +
`activate. Build the graph with .entryBy(keywordScorer()) or .entryByRelevance(embedder) ` +
`(object form: start: { entries, scoredBy }), or drop the injection. Note .entryByRead() ` +
`does NOT score — there the model picks, so there is no near-tie to report.`);
}
}
/**
* Dev-warn a `skillScopedTools(id, …)` pointed at a skill that already scopes its own
* tools (8.7.0).
*
* `defineSkill({ tools, autoActivate: 'currentSkill' })` already narrows the LLM's
* tool list to the active skill, with no provider at all — that is what
* `skillScopedTools`' own header means by "you probably don't need this". Wiring both
* gives one skill two tool sources keyed on two DIFFERENT signals (the graph's active
* set vs. the last `read_skill` call), which is how a tool ends up visible on
* iterations nobody expected — and it is the exact pairing that put a shadowed tool
* name into a field agent.
*/
function warnRedundantSkillScopedTools(provider, injections) {
if (!isDevMode())
return;
const target = skillScopedToolsTarget(provider?.id);
if (target === undefined)
return;
const skill = injections.find((i) => i.id === target);
if (!skill)
return;
const meta = skill.metadata;
if (meta?.autoActivate !== 'currentSkill')
return;
if ((skill.inject?.tools ?? []).length === 0)
return;
// eslint-disable-next-line no-console
console.warn(`agentfootprint Agent: skillScopedTools('${target}') is wired, but skill '${target}' already ` +
`carries autoActivate: 'currentSkill' with its own tools:[] — which narrows the tool list ` +
`to that skill on its own. The two gates key on DIFFERENT signals (the provider on the ` +
`last read_skill call, the skill on the graph's active set), so a tool can appear on ` +
`iterations you did not expect, and a name declared on both sides shadows (the model ` +
`reads the provider's description and the skill's implementation runs). Keep one: the ` +
`skill's tools:[] if the tools belong to the skill, the provider if they are assembled ` +
`elsewhere.`);
}
/**
* Validate `.outputSchema(…, { retries })`.
*
* The ceiling is stated rather than left open: a model that has missed the
* shape ten times in a row is not going to find it on the eleventh, and the
* run would be spending real money and real iterations discovering that. When
* ten is not enough the answer is a different schema or a different model,
* not a longer loop.
*/
function resolveRetries(retries) {
if (retries === undefined)
return 0;
if (!Number.isInteger(retries) || retries < 0) {
throw new Error(`AgentBuilder.outputSchema: { retries } must be a non-negative integer, got ${String(retries)}. It counts corrective re-asks — 0 (the default) means the first answer is the only one.`);
}
if (retries > MAX_OUTPUT_RETRIES) {
throw new Error(`AgentBuilder.outputSchema: { retries: ${retries} } exceeds the ceiling of ` +
`${MAX_OUTPUT_RETRIES}. Each retry is a real turn — a real request, real tokens, real ` +
`money — and a model that has missed the shape ${MAX_OUTPUT_RETRIES} times running is ` +
`not about to find it. If that is genuinely not enough, the fix is a simpler schema, a ` +
`clearer instruction, or a different model.`);
}
return retries;
}
/** The stated ceiling on corrective re-asks. See {@link resolveRetries}. */
const MAX_OUTPUT_RETRIES = 10;
//# sourceMappingURL=AgentBuilder.js.map