agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
3,915 lines • 222 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AgentBuilder = void 0;
const instruction_js_1 = require("./findings/instruction.js");
const context_js_1 = require("./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.
*/
const footprintjs_1 = require("footprintjs");
const index_js_1 = require("../../answer-validation/index.js");
const outputSchema_js_1 = require("../../answer-validation/outputSchema.js");
const outputEnforcement_js_1 = require("./outputEnforcement.js");
const nativeOutputFormat_js_1 = require("./nativeOutputFormat.js");
const config_js_1 = require("./negativeClaims/config.js");
const gate_js_1 = require("./evidence/gate.js");
const toolsFromActiveSkill_js_1 = require("./toolsFromActiveSkill.js");
const outputFallback_js_1 = require("../outputFallback.js");
const skillGraph_js_1 = require("../../injection-engine/skillGraph.js");
const skillBrains_js_1 = require("./skillBrains.js");
const thinkingSupport_js_1 = require("./thinkingSupport.js");
const defineMenuHint_js_1 = require("../../injection-engine/factories/defineMenuHint.js");
const defineStepsHint_js_1 = require("../../injection-engine/factories/defineStepsHint.js");
const skillSteps_js_1 = require("../../injection-engine/skillSteps.js");
const skillContract_js_1 = require("../../injection-engine/skillContract.js");
const skillGraphCheckup_js_1 = require("../../injection-engine/skillGraphCheckup.js");
const skillVocabulary_js_1 = require("../../injection-engine/skillVocabulary.js");
const skillBodyDelivery_js_1 = require("../../injection-engine/skillBodyDelivery.js");
const defineInstruction_js_1 = require("../../injection-engine/factories/defineInstruction.js");
const messagesSlotRefusal_js_1 = require("../../injection-engine/messagesSlotRefusal.js");
const defineRelevanceHint_js_1 = require("../../injection-engine/factories/defineRelevanceHint.js");
const skillScopedTools_js_1 = require("../../tools/skillScopedTools.js");
const commentaryTemplates_js_1 = require("../../recorders/observability/commentary/commentaryTemplates.js");
const statusTemplates_js_1 = require("../../recorders/observability/status/statusTemplates.js");
const selfExplainPort_js_1 = require("./selfExplainPort.js");
const servingConversation_js_1 = require("./servingConversation.js");
const innerRunRecords_js_1 = require("../innerRunRecords.js");
const traceToolNames_js_1 = require("../../lib/traceToolNames.js");
const Agent_js_1 = require("../Agent.js");
const skillGraphDeclared_js_1 = require("./skillGraphDeclared.js");
const reserved_js_1 = require("./findings/reserved.js");
const armed_js_1 = require("./honesty/armed.js");
const clock_js_1 = require("../../time/clock.js");
const instruction_js_2 = require("../../ontology/instruction.js");
const act_js_1 = require("./act.js");
const options_js_1 = require("./window/options.js");
const summarizeOldest_js_1 = require("./window/strategies/summarizeOldest.js");
const defineAgentRecipe_js_1 = require("../../recipes/defineAgentRecipe.js");
const attachment_js_1 = require("./scripts/attachment.js");
const script_js_1 = require("../../tools/script.js");
const apply_js_1 = require("../../recipes/apply.js");
const provenance_js_1 = require("../../recipes/provenance.js");
/**
* Fluent builder. `tool()` accepts any Tool<TArgs, TResult> and registers
* it by its schema.name. Duplicate names throw at build time.
*/
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
? provenance_js_1.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 (0, apply_js_1.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) {
(0, defineAgentRecipe_js_1.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.
(0, apply_js_1.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((0, apply_js_1.recursiveRecipeRefusal)({
stack: [...this.recipeStack],
incoming: applied,
callSite: 'AgentBuilder.recipe',
}));
}
const already = this.appliedRecipeList.find((r) => r.id === applied.id);
if (already) {
throw new Error((0, apply_js_1.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((0, apply_js_1.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 = (0, act_js_1.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 = (0, options_js_1.resolveCompactionOptions)(options, 'AgentBuilder.compaction');
this.assertSummarizerIsNotTheAgentItself({ provider: resolved.summarizer, model: resolved.model }, 'AgentBuilder.compaction');
this.windowStrategyValue = (0, summarizeOldest_js_1.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((0, messagesSlotRefusal_js_1.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 = (0, skillGraphDeclared_js_1.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 = (0, gate_js_1.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 = (0, attachment_js_1.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 = (0, clock_js_1.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 ?? (0, outputSchema_js_1.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((0, defineInstruction_js_1.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 = (0, context_js_1.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((0, instruction_js_1.findingsInstruction)(false, answerAsk === 'quote-facts'));
this.injectionSources.set(reserved_js_1.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((0, defineInstruction_js_1.defineInstruction)({
id: instruction_js_2.ONTOLOGY_INSTRUCTION_ID,
activeWhen: () => true,
prompt: instruction_js_2.ONTOLOGY_INSTRUCTION,
}));
this.injectionSources.set(instruction_js_2.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) {
(0, outputFallback_js_1.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 = (0, index_js_1.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 = (0, config_js_1.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 '${outputEnforcement_js_1.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 = (0, outputEnforcement_js_1.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 = (0, outputEnforcement_js_1.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 = (0, outputEnforcement_js_1.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 = (0, nativeOutputFormat_js_1.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 nativeOutputFormat_js_1.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 = (0, config_js_1.negativeClaimsSchema)(negativeClaims, (0, outputEnforcement_js_1.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: { ...commentaryTemplates_js_1.defaultCommentaryTemplates, ...this.commentaryOverrides },
thinkingTemplates: { ...statusTemplates_js_1.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 ||
(0, armed_js_1.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 ? (0, selfExplainPort_js_1.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']
: traceToolNames_js_1.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 === reserved_js_1.FINDINGS_INSTRUCTION_ID);
if (at >= 0) {
this.injectionList[at] = (0, instruction_js_1.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,
(0, defineInstruction_js_1.defineInstruction)({
id: 'negative-claims',
activeWhen: () => true,
prompt: (0, config_js_1.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?.[defineMenuHint_js_1.MENU_HINT_METADATA_KEY])) {
injections = [...injections, (0, defineMenuHint_js_1.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 = (0, toolsFromActiveSkill_js_1.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 = (0, skillSteps_js_1.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 ((0, footprintjs_1.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?.[defineStepsHint_js_1.STEPS_HINT_METADATA_KEY])) {
injections = [...injections, (0, defineStepsHint_js_1.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 = (0, skillBodyDelivery_js_1.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?.[skillGraph_js_1.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) => (0, skillContract_js_1.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) => (0, skillContract_js_1.skillToolNames)(s)),
];
const problems = (0, skillContract_js_1.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${(0, skillGraphCheckup_js_1.formatCheckup)(report)}`);
}
if ((0, footprintjs_1.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` +
(0, skillGraphCheckup_js_1.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 = (0, skillVocabulary_js_1.checkArtifactVocabularies)(injections);
if (vocabularyProblems.length > 0 && (0, footprintjs_1.isDevMode)()) {
// eslint-disable-next-line no-console
console.warn(`agentfootprint Agent: artifact-vocabulary check-up:\n` +
(0, skillGraphCheckup_js_1.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 = (0, skillBrains_js_1.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) {
(0, thinkingSupport_js_1.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) => (0, script_js_1.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 &&
(0, footprintjs_1.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') {
(0, config_js_1.assertNegativeClaimsTools)(negativeClaims, [
...this.registry.map((entry) => entry.tool),
...injections.flatMap((injection) => injection.inject?.tools ?? []),
]);
}
const agent = new Agent_js_1.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: () => (0, servingConversation_js_1.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;
}
}
exports.AgentBuilder = AgentBuilder;
/**
* 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 = (0, innerRunRecords_js_1.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 (0, innerRunRecords_js_1.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 || !(0, footprintjs_1.isDevMode)())
return;
for (const inj of injections) {
const meta = inj.metadata;
if (meta?.[defineRelevanceHint_js_1.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 (!(0, footprintjs_1.isDevMode)())
return;
const target = (0, skillScopedTools_js_1.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