/** * AgentBuilder — fluent builder for Agent. Extracted from Agent.ts in * v2.11.2 as part of the core/agent decomposition. Same surface, same * behavior; just lives in its own file for readability. * * Re-exported by Agent.ts so external consumers importing * `AgentBuilder` from `'../core/Agent.js'` continue to work. */ import { type OutputSchemaOptions, type OutputSchemaParser } from '../outputSchema.js'; import type { NamesAndNumbersOptions } from './evidence/types.js'; import { type OutputFallbackOptions } from '../outputFallback.js'; import type { CachePolicy } from '../../cache/types.js'; import type { MapsOptions } from '../../maps/engagement/types.js'; import type { Injection, InjectionContext } from '../../lib/injection-engine/types.js'; import { type CursorMove, type EntryScoring, type TurnRoutingPlan } from '../../lib/injection-engine/skillGraph.js'; import { type EscalationPolicy, type ProviderChoice } from './skillBrains.js'; import type { MemoryDefinition } from '../../memory/define.types.js'; import type { ReliabilityConfig } from '../../reliability/types.js'; import type { ThinkingHandler } from '../../thinking/types.js'; import type { Tool } from '../tools.js'; import type { CheckInBuilderOptions } from '../checkin.js'; import type { ToolProvider } from '../../tool-providers/types.js'; import type { Watcher } from './watch.js'; import { type SelfExplainOptions } from '../../lib/trace-toolpack/selfExplain.js'; import { Agent } from '../Agent.js'; import type { AgentOptions, RunConfigFn } from './types.js'; import type { CompactionOptions } from './window/types.js'; import type { WindowStrategy } from './window/strategy.js'; import type { MessageMiddleware, ToolMiddleware } from './middleware/types.js'; import { type ActOptions } from './act.js'; import type { AgentRecipe, RecipeOptions } from '../../recipes/types.js'; /** * Mount options for `.skillGraph(graph, options)` (SG-C, 9.17.0; brains * SG-D, 9.19.0). Every field is zero-cost when absent — an agent that passes * none is byte-identical in behavior AND events to one built before the * options existed. */ export interface SkillGraphOptions { /** * How much routing authority the model has. Default `'assist'` — today, * always: any REACHABLE `read_skill` pick is admitted, and a pick off an * offered menu is stamped on the record (`cursorMove.declinedOffer`) * rather than refused. * • `'guard'` — a routing pick is admitted only while the turn's menu is * outstanding AND names an offered id (the framework declared the * ambiguity; the model resolves exactly that). Everything else gets a * teaching refusal + `skill.rejected { posture: 'guard' }`. * • `'rails'` — the model never routes: turn starts resolve by rule or * scorer, transitions by declared routes. A menu verdict then proceeds * on the base prompt with `turn_routed { by: 'none' }` recorded — the * honest cost of rails without a resolver. OPEN skills * (`.selfExplain()`, `.skill()` beside the graph) stay admitted from * anywhere under every posture. * * WHAT A POSTURE GOVERNS, EXACTLY: the MODEL's routing door — a `read_skill` * hop — and nothing else. It is NOT a lock on the cursor. Two other doors * stay open under all three postures, deliberately: * • OPEN skills, above — the debugging door. * • A `propose-transition` tool effect. A proposal comes from a TOOL — * deterministic code you shipped, not the model's guess — so it is * framework-tier evidence: admitted under `assist`, `guard` AND * `rails`, checked only against the graph's own reachability law, and * recorded as `cursorMove.by: 'tool-proposal'` (the resolver ranks it * BETWEEN a declared edge and a model pick, for that reason). * So `'rails'` means "the MODEL never routes", never "nothing but my * declared edges routes": a tool of yours that proposes IS a route you * declared, in code instead of in the graph. To close that door, don't * ship the effect — the posture will not second-guess your own code. */ readonly strictness?: 'assist' | 'guard' | 'rails'; /** * What the cursor spans. Default `'turn'` — today's per-run cursor, * unchanged. `'conversation'`: the turn's final cursor rides the * conversation checkpoint (`agent.checkpoint()` / the crash carrier) and * becomes the DEFAULT entry when that conversation is continued * (`followUp()` / `run({ continueFrom })`) — a sticky default the new * message can still decisively beat, never a lock. Without `continueFrom` * nothing carries: a bare second `run()` starts cold exactly as today — * this option changes what a CONTINUED conversation defaults to; it does * not invent persistence. */ readonly continuity?: 'turn' | 'conversation'; /** * Per-skill BRAINS (9.19.0) — "the cursor picks the brain": while the * graph's cursor is on a named skill, `callLLM` runs on its declared * provider/model instead of the agent's. Keys are skill ids; an id that * is not a graph node is refused at build, as is a foreign provider with * no model (the agent's model id belongs to another vendor's namespace). * The other declaration home is `defineSkill({ provider, model })` — the * same id in both homes with different choices is refused naming both. */ readonly providers?: Readonly>; /** * Escalate-on-evidence (9.19.0): `afterRefusals` recorded gate refusals * (`skill.rejected` — reachability, posture, OR a self-call: all three * refusal arms count) in ONE turn flip the rest * of the turn onto this brain — `skill.escalated` goes on the record at * the flip, and the next turn's seed de-escalates. Never on vibes: only * real refusals count. */ readonly escalation?: EscalationPolicy; /** * The tier-3 DECIDER (9.19.0): an out-of-band constrained pick over an * outstanding turn-start menu ∪ {stay} (the `llmClassifier` enum * machinery), resolved before the loop — `turn_routed { by: 'decider' }`. * The sanctioned resolver for `'rails'` menus: constrained, off-loop, and * recorded, i.e. a scorer in posture terms. Needs a graph that runs the * turn-start cascade (a classifier, or `continuity: 'conversation'`) — * refused at build otherwise, because no other graph ever has a menu for * it to resolve. */ readonly decider?: ProviderChoice; } /** * Fluent builder. `tool()` accepts any Tool and registers * it by its schema.name. Duplicate names throw at build time. */ export declare class AgentBuilder { private readonly opts; private 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. */ private systemPromptSet; /** * 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. */ private systemPromptCachePolicy; /** * 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. */ private cachingDisabledValue; /** * Optional explicit CacheStrategy override. Default: undefined, * which means the agent auto-resolves from * `getDefaultCacheStrategy(provider.name)` at construction. Power * users override here for custom backends or test mocks. */ private cacheStrategyOverride?; private readonly registry; private readonly 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. */ private 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). */ private 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. */ private 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). */ private 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. */ private 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. */ private 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. */ private skillGraphIsTree; /** 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. */ private 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. */ private 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. */ private 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`. */ private 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. */ private 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. */ private mapsOptions?; /** `.claims()` (9.61.0) — the declared claim contract; resolved at build(). */ private claimsContract?; private readonly memoryList; /** * Optional terminal contract — see `outputSchema()`. Stored on the * builder, propagated to the Agent at `.build()` time. */ private outputSchemaParser?; /** 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. */ private 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. */ private toolsFromActiveSkillValue; /** `.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. */ private limitsTravelValue; private outputSchemaRetries; private outputSchemaStrategy; private outputSchemaJson?; /** 3-tier output fallback chain — set via `.outputFallback({...})`. * Optional; absent = current throw-on-validation-failure behavior. */ private 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. */ private toolProviderRef?; /** * Optional override for `AgentOptions.maxIterations`. When set via * the `.maxIterations()` builder method, takes precedence over the * value passed to `Agent.create({ maxIterations })`. */ private maxIterationsOverride?; /** * Observers collected via `.watch()`. Attached to the built Agent before `build()` returns * (each via `agent.attach(rec)`), in call order. */ private readonly recorderList; private appNameValue; private commentaryOverrides; private 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. */ private reliabilityConfig?; /** * Optional ThinkingHandler (v2.14+). Three states: * - undefined (default): auto-wire by `provider.name` via * `findThinkingHandler` from the registry * - explicit handler: override the auto-wire * - explicit `null`: opt out (no thinking handler mounted at all, * even if the provider would auto-match) * * 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. */ private 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). */ private thinkingBudgetValue?; private selfExplainConfig?; private checkInConfig?; /** Per-run config resolver set via `.configure()`. Undefined = the agent * runs on its build-time model + system prompt, unchanged. */ private 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. */ private 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. */ private windowStrategyDoor?; /** The tool-dispatch chain, in call order. Empty = no chain, no ledger. */ private toolMiddlewareList; /** The message chain, in call order. Empty = no chain, no ledger. */ private messageMiddlewareList; /** `.act()` is the posture block: one per agent. See the method. */ private actCalled; /** 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. */ private readonly 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. */ private readonly 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. */ private readonly toolSources; private readonly injectionSources; constructor(opts: AgentOptions); /** * 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: string, options?: { readonly cache?: CachePolicy; }): this; tool(tool: Tool): 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. */ private currentSource; /** * 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. */ private duplicateRefusal; /** * 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: AgentRecipe, options?: RecipeOptions): 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: ReadonlyArray): 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: ToolProvider): 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: RunConfigFn): 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: ActOptions): 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: WindowStrategy): 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. */ private assertSummarizerIsNotTheAgentItself; /** * 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: CompactionOptions): 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. */ private assertNoWindowStrategy; /** Record which door set the window strategy, for the refusal above. */ private noteWindowStrategyDoor; /** * 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: number): 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: readonly Watcher[]): this; /** * REMOVED in 9.0.0 — use {@link AgentBuilder.watch} instead. * * This is a one-release grace error, not a method. Deprecated in 8.0.0 in * favour of `.watch(...)` — same list, same order, same attachment, and * `.watch()` takes more than one observer. The body was deleted in 9.0.0; * the NAME is kept for one major so a call site that missed the deprecation * gets a sentence instead of `builder.recorder is not a function`. * * It throws at BUILD time, before any run, so the failure is deterministic * and lands in development rather than in a trace nobody is watching. * * @deprecated Removed in 9.0.0 — call `.watch(rec)`. This throwing stub is * deleted in 10.0.0. */ recorder(_rec: Watcher): 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: string): this; /** * Override agentfootprint's bundled commentary templates. Spread on * top of `defaultCommentaryTemplates`; missing keys fall back. Same * `Record` 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: Readonly>): 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.` keys (e.g., `'tool.weather': 'Looking up the * weather…'`). See `defaultStatusTemplates` for the full key list. */ thinkingTemplates(templates: Readonly>): this; /** * 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: Injection): 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: Injection): this; /** * 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: { list(): readonly Injection[]; }): 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: { skills: readonly Injection[]; nextSkill: (ctx: InjectionContext) => string | undefined; reachableSkills?: (currentSkillId?: string) => readonly string[]; scoreEntries?: (ctx: InjectionContext, signal?: AbortSignal) => Promise; /** The declared edges. Read for ONE thing: which skills the graph wires, so the * read_skill gate can tell a skill the graph routes from one it never mentions * (see `openSkillIds` in `build()`). Optional for forward-compat with graphs * built before `edges` existed; absent → the graph wires nothing. */ edges?: ReadonlyArray<{ readonly to: string; /** The declared source (`null` = the synthetic START) — read since * 9.50.0 for the `skill.graph_declared` record. Optional for * forward-compat; an edge that omits it is routed exactly as before * but stays OFF the declared-map event (never completed by a guess). */ readonly from?: string | null; /** The declared `SkillEdgeKind` — same 9.50.0 record, same posture. */ readonly kind?: string; /** The author's caption — same 9.50.0 record, same posture. */ readonly label?: string; /** The edge's declared DATA guard (9.51.0) — read for the * `skill.graph_declared` record only, carried verbatim when it has * the compiled shape and skipped otherwise (never completed). */ readonly guard?: { readonly conditions?: unknown; }; }>; /** The same cursor resolver, reporting the clause that won (8.5.0). Optional for * forward-compat; absent → no `cursorMove` on `context.evaluated`. */ explainNextSkill?: (ctx: InjectionContext) => CursorMove; /** The entries the cursor law superseded this iteration (8.15.0). Optional for * forward-compat; absent → no `supersededIds` on `context.evaluated`. */ supersededEntries?: (ctx: InjectionContext) => readonly string[]; /** The drawn nodes. Read for TWO things: a `predicate` node means this graph is * a decision `tree()` (the gate's refusal says so out loud), and the node-id * set is what a continuity cursor is validated against (`droppedResume`). * Derived here rather than added to `SkillGraph` as a mode field — the shape * is already public, and one fact should not be declared twice. */ nodes?: ReadonlyArray<{ readonly kind: string; readonly id?: string; /** The drawn caption (predicate diamonds) — read since 9.50.0 for the * `skill.graph_declared` record only. */ readonly label?: string; }>; /** The graph's turn-routing plan (SG-C) — tier-1 rules, intent candidates, * the classifier and the resolved tie policy. Optional for forward-compat * with graphs built before it existed; absent → the cascade cannot run * (classify needs it; continuity degrades to nothing rather than guess). */ turnRouting?: TurnRoutingPlan; /** How the graph picks a turn's starting entry (SG-C). Read for one * refusal: `strictness: 'rails'` cannot honor `'model-read'`. */ entrySelection?: 'scorer' | 'model-read' | 'classify'; /** The graph's note that it deferred its body-contract checks to agent build * (built without `knownTools` — see `SkillGraph.deferredBodyContract`). * Optional for forward-compat; absent → the checks already ran at graph build * (or were off), so this agent never re-runs them. Library-built graphs also * stamp the note on each compiled skill's metadata, which `build()` prefers — * this field is the fallback for a structurally-typed graph without the * per-skill stamps (skills found by both are deduped by id). */ deferredBodyContract?: { readonly mode: 'throw' | 'warn'; }; }, options?: SkillGraphOptions): 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?: MapsOptions): 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. * * 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 semantic({ facts: [...] }) * .outputSchema(AnswerSchema) * .claims({ nav_count: { entity: 'screen2', field: 'nav' } }) * .build(); */ claims(contract: Readonly>): this; /** * Register a Steering doc — always-on system-prompt rule. * Use for invariant guidance: output format, persona, safety policies. */ steering(injection: Injection): this; /** * 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: Injection): this; /** * 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: ReadonlyArray): 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: Injection): this; /** * 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: MemoryDefinition): 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: MemoryDefinition): this; /** * 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, the answer is validated in the loop, and 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). 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?: NamesAndNumbersOptions): 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**. * * ## 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. * * @example * const agent = Agent.create({ provider, model }) * .tool(replicationHealth) // returns coverage(verdict, { … }) * .limitsTravelWithTheAnswer() * .build(); */ limitsTravelWithTheAnswer(): 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 in 9.x.** The default is unchanged — an agent that never calls * this builds byte-identical bytes and emits byte-identical events. The * default flips in 10.0.0, the same ledger `skillGraph({ scopeTools })` is on. * * @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(): this; outputSchema(parser: OutputSchemaParser, opts?: OutputSchemaOptions): 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: OutputFallbackOptions): 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. */ private assertOutputFallbackCoherent; /** * 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/reliability'; * * 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: ReliabilityConfig): this; /** * Wire a thinking handler (v2.14+). Three usage patterns: * * • OMITTED (default) — framework auto-wires by `provider.name` via * `findThinkingHandler` from the registry. Most consumers using * a shipped provider get thinking support for free. * * • EXPLICIT handler — override the auto-wire. For custom providers * or for swapping in a custom Anthropic/OpenAI handler with * different normalization (e.g. redacting blocks before they * land). * * • EXPLICIT `null` — opt out entirely. The thinking subflow is NOT * mounted even if the provider would auto-match. Use when you * want to skip thinking parsing for this agent (cost / latency / * UX reasons). * * Calling twice throws — same shape as `.reliability()` / * `.outputSchema()` to enforce single-source intent. * * @example * // Default — auto-wire AnthropicThinkingHandler for anthropic provider * Agent.create({ provider: anthropic({...}), model: '...' }).build(); * * @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: ThinkingHandler | null): 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 AnthropicProvider * translates to `thinking: { type: 'enabled', budget_tokens: N }` * on the wire. The model spends up to `budget` reasoning tokens * before producing the visible response. * * **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: requires claude-sonnet-4-5 / opus-4-5 (or newer). * Older models reject with HTTP 400. * - OpenAI: ignores. o1/o3 reasoning is selected at the model id * level; this field is a no-op for OpenAIProvider. * * **Budget guidance:** Anthropic recommends 1024-32000 reasoning * tokens. `budget` MUST be less than the request's `max_tokens` * (defaults to 4096 in AnthropicProvider — bump via the request * `maxTokens` if budget > ~3000). * * Calling twice throws — same shape as `.reliability()` / * `.outputSchema()`. * * @example * Agent.create({ provider: anthropic({...}), model: 'claude-sonnet-4-5' }) * .system('You are a careful reasoning agent.') * .thinking({ budget: 5000 }) // ask Anthropic to think * .build(); */ thinking(opts: { budget: number; }): 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: ` — 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: readonly ToolMiddleware[]): 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. */ private assertToolMiddleware; /** * 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 where the final answer is captured, so the record and the caller * receive the same string. * * `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: readonly MessageMiddleware[]): 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. */ private assertMiddleware; checkIn(opts?: CheckInBuilderOptions): this; selfExplain(opts?: SelfExplainOptions): 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. */ private resolveMapsPlan; private resolveOutputEnforcement; build(): Agent; }