Agent
Agent = ReAct. The loop primitive that thinks, acts (tool call), observes the result, and repeats until done.
A user asks "what's the weather in Paris?". Your code can't just hit a weather API — the LLM has to decide which tool to call, with what args, then read the result, then decide whether to call another tool or respond. That decide → act → observe → repeat is what makes an Agent an Agent, not a function call.
Agent = ReAct
The Agent primitive is the ReAct loop (Yao 2022). One iteration:
LLM call → route → [tool calls → loop] OR [final answer]Each iteration: the LLM produces text + optional tool calls. If tools were called, the framework executes them, appends results to the message history, and starts another iteration. If no tools were called (or maxIterations is hit), the loop exits with the final text.
If it doesn't loop-with-tools, it isn't an Agent — it's an LLMCall.
Build an agent
Agent.create({ provider, model }) → builder. .system(...) sets the system prompt. .tool(...) registers tools (each with a JSON schema). .build() finalizes:
const agent = Agent.create({ provider: provider ?? exampleProvider('feature', { respond: weatherRespond }), model: 'mock', maxIterations: 5, // reactMode: 'dynamic-grouped' wraps the LLM turn in an sf-llm-call subflow, // so Lens renders the agent's reasoning as an LLM group with its context // slots (system-prompt / messages / tools) nested inside — the SAME shape // the LLMCall primitive shows — instead of a bare "Final · RUNNER" card. reactMode: 'dynamic-grouped',}) .system('You answer weather questions using the `weather` tool.') .tool({ schema: { name: 'weather', description: 'Get current weather for a city.', inputSchema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'], }, }, execute: async (args) => `${(args as { city: string }).city}: sunny, 72°F`, }) .build();The framework owns the iteration loop. You declare what tools the agent has; the LLM decides when (and with what args) to call them; the framework dispatches and feeds results back.
What run() takes
A message, in either spelling:
await agent.run('what is the weather in Paris?'); // a bare string IS the message
await agent.run({ message: 'what is the weather in Paris?' }); // identical, byte for byte
await agent.run({ message: '…', identity: { tenant: 'globex', conversationId: 'c2' } });Anything that is not a message is refused before the run starts, with an InvalidRunInputError naming the door and what arrived — {}, { message: 42 }, null, and an empty or whitespace-only message. Nothing is billed and no half-run has to be explained.
An empty message is refused rather than sent: it is not a shorter question, it reaches a provider as a turn with no content, and real wires reject it. To run on the system prompt alone, say so in the message (run({ message: 'begin' })).
The same rule holds for LLMCall, Sequence, Parallel, Conditional and Loop — one door, one answer.
Observe the loop
Because the framework owns the loop, observability is just attaching listeners — no SDK, no agent-instrumentation wrapper:
agent.on('agentfootprint.stream.tool_start', (e) => console.log(`→ tool ${e.payload.toolName}(${JSON.stringify(e.payload.args)})`),);agent.on('agentfootprint.stream.tool_end', (e) => console.log(`← tool result: ${e.payload.result}`),);124 typed events fire across 27 domains during a single agent.run(). See the Observability guide for the full taxonomy.
maxIterations
Every Agent has a maxIterations cap (default 10). The loop exits when:
- The LLM returns text with no tool calls (normal completion)
maxIterationsis reached (forced exit — see Running out of budget below)- A tool throws (propagates as an error; subject to
withRetryif wrapped) - The agent pauses via
askHuman/pauseHere(run()returns aRunnerPauseOutcomeinstead of a string — narrow it with theisPaused()guard)
Tune maxIterations to your tool budget. Tool-heavy agents (research, code-gen) commonly run 15–30; chat agents 3–5.
Running out of budget
maxIterations is a cap on actions, and the model does not know it is about to be hit.
Through 9.55.0, a turn that reached the cap while the model was still asking for tools handed
back whatever text happened to ride that last call. Mid-task, that is a fragment:
"The third finding focus is not settling… Let me check what's on screen now:"
That sentence reached the person as if it were the answer, with nothing on the record saying the budget had run out.
Since 9.56.0 the run spends one more LLM call with the tools withheld, carrying one instruction, and hands back what comes back:
[budget exhausted — the action budget was exhausted before the wrap-up call this message opened, so no tools were offered on that call. That call was for the final answer, from what the messages above already hold: what was completed, what remained undone, and anything the person should know.]
The bracketed opening is not decoration: it is the marker that tells the window's refusal engine
and a routing rule reading history that the library wrote this message, not the person (see
isSaidByPerson). And every clause reports the call it was written for in the past tense,
because the message stays in history and is re-read on every later call of the turn.
That call is exempt from maxIterations by construction, not by exception: a model offered no
tools has nothing to ask for, so it can only answer. It costs one call, and it is on the record
like any other turn — its own iteration_start, llm_start and cost.tick.
The fact is on the record three ways — agent.stoppedEarly(),
agentfootprint.agent.budget_exhausted, and a stoppedEarly field on
agentfootprint.agent.turn_end — so a dashboard can tell answered from answered after the
budget ran out:
const answer = await agent.run({ message: 'Audit the three findings' });
agent.stoppedEarly();
// { reason: 'max-iterations', iteration: 8, pendingToolCalls: 1,
// answerWasEmpty: false, wrappedUp: true }
agent.on('agentfootprint.agent.budget_exhausted', (e) => e.payload);
// { reason: 'max-iterations', iteration: 8, limit: 8,
// pendingToolCalls: 1, action: 'wrapped-up' }
agent.on('agentfootprint.agent.turn_end', (e) => e.payload.stoppedEarly);
// { reason: 'max-iterations', iteration: 8, pendingToolCalls: 1, wrappedUp: true }It rides the iteration budget only. A halting costBudget keeps the old behaviour, because
there you capped the money and one more call would spend past the cap — an action cap says
nothing about a call that takes no action.
Turn it off with wrapUpAtMaxIterations: false (the fact is still recorded, as
action: 'cut-short'). A turn that never runs out of budget is byte-identical either way —
same calls, same events, same committed state.
const seen: { tools: number }[] = [];const agent = Agent.create({ provider: provider ?? busyModel(seen), model: 'demo-sonnet', maxIterations: 2, // a deliberately small action budget}) .tool(inspect as never) .build();let exhausted: Record<string, unknown> | undefined;agent.on('agentfootprint.agent.budget_exhausted', (e) => { exhausted = e.payload as unknown as Record<string, unknown>;});const answer = await agent.run({ message: input });const cut = agent.stoppedEarly();Identity for multi-tenant memory
If your agent uses memory (.memory(...)), every .run() call must include an identity so memory is scoped per tenant / principal / conversation:
await agent.run({
message: 'How long do refunds take?',
identity: { tenant: 'acme', principal: 'alice', conversationId: 'thread-42' },
});Without an identity, memory is scoped to the run's own conversation ({ conversationId: <runId> }), so nothing is recalled across runs — fine for a script, not for an app whose users come back. See Memory guide.
Per-run config — .configure()
An agent is built once and run many times, but not every run wants the same model or the same house rules. A long message may deserve the bigger model; a tenant may have its own policy text; 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 — the trace then describes an agent that no longer exists.
.configure((ctx) => ({ model?, instructions? })) resolves once per run, at
the start of the run:
const agent = Agent.create({ provider: llm, model: 'small-model' }) .system('You answer support questions.') .configure(({ message, identity, defaults }) => ({ // Route to the bigger model only when the question is big. ...(message.length > 40 ? { model: 'big-model' } : {}), // Tenant rules land on top of the built-in prompt. `defaults` carries // what the agent was BUILT with, so nothing has to be restated here. instructions: `${defaults.instructions}\n${HOUSE_RULES[identity?.tenant ?? ''] ?? ''}`.trim(), })) .build();ctx is a RunConfigContext: the run's message, its identity (when
run({ identity }) supplied one), its runId, and defaults — what the agent
was built with, so a resolver can decide relative to it rather than restating
it. The return value is a RunConfig; the resolver's own type is RunConfigFn.
Typing a resolver that lives somewhere else
The inline form above needs no annotations — TypeScript infers ctx from
.configure(). A resolver you keep in its own module does need them, so
RunConfigFn, RunConfigContext and RunConfig are all exported from
agentfootprint. Import them rather than re-declaring the shape by hand: a
hand copy stops matching the moment the context grows a field, and it stops
matching silently.
import type { RunConfig, RunConfigContext, RunConfigFn } from 'agentfootprint';
// A named resolver, testable on its own and shared across agents.
export const pickBrain: RunConfigFn = (ctx) =>
ctx.message.length > 500 ? { model: 'big-model' } : {};
// A factory naming its own return type.
export function houseRulesFor(tenant: string) {
return (ctx: RunConfigContext): RunConfig => ({
instructions: `${ctx.defaults.instructions}\n\n${rulesFor(tenant)}`,
});
}
const agent = Agent.create({ provider, model: 'small-model' })
.system('You answer support questions.')
.configure(pickBrain)
.build();What gets resolved gets committed
This is the part that matters. A run that changed its own model without recording it would be a trace that lies about its most expensive fact — you would read the recording, see the model the agent was constructed with, and be wrong about which one answered.
So the resolved values ride the same commit that already carries the run's other
run-level facts (identity, iteration budget, turn number): resolvedModel and
resolvedInstructions land in the commit log before the first LLM call, and
the LLM call reads them from there. One value, used and recorded:
await agent.run({ message, identity: { tenant: 'globex', conversationId: 'c2' } });
const state = agent.getLastSnapshot()?.sharedState;
state.resolvedModel; // 'big-model' — what actually answered
state.resolvedInstructions; // the rules that run actually ran underThe agentfootprint.stream.llm_start event reports the resolved model too, and
cost is priced against it — there is no second copy to drift.
Absent means unchanged
Omit .configure() and every run behaves, and records, exactly as it did before:
no extra scope writes, no extra scope reads, and the request bytes are identical.
The same is true of a resolver that returns {} or nothing — only what it
actually returned is committed, so "I looked and decided not to change anything"
costs nothing in the log.
This is the run axis only
Tools are the iteration axis and already have an owner: .toolProvider(),
consulted every iteration so gates can react to what just happened. .configure()
deliberately does not duplicate it, and does not reach past model and
instructions into the rest of the run — temperature, maxTokens and the tool
set stay where they were set.
Calling .configure() twice throws. A silently-overridden resolver is a config
that lies.
Findings on the record — .findings()
Call .findings() on the builder and every served tool schema gains one reserved, optional argument, _findings (the constant RESERVED_ARGUMENT). On each tool call the model declares why it is calling — a Basis of direct or exploratory, with an optional Expect bucket of low, medium or high — and, on its next tool call or as a top-level key of a JSON answer, the Standing of each earlier tool result: fact, open, noise or ruled-out. The wire shape of that declaration is FindingsDeclaration.
The library peels the argument off before the tool, the permission gate, middleware or any argument validation sees it, and appends what was declared to one state key, findingsLedger, typed FindingsLedger: a list of FindingsRow entries, where a BasisRow records a declared call, a StandingRow records a declared standing together with the assertions the model stands on, and a ConflictRow names, by ConflictWitness identities only, two standing facts that disagree. Nothing is inferred: a call that carries no declaration writes no row, and a result the model never names is undeclared — never open. Read the ledger back with agent.findings().
const agent = Agent.create({ provider, model }).tool(search).tool(fetchPage).findings().build();
await agent.run({ message: 'Which region is the outage in?' });
agent.findings(); // basis, standing and conflict rows, in the order they were declaredThere are no extra model calls, and an agent that never calls .findings() records exactly the bytes it recorded before.
The answer gets the same treatment, with or without .outputSchema(): a _findings the model writes into a JSON object in its answer — the whole answer, a code block, one written in the prose — is taken off the answer run() returns and the run records, and its standings are filed with declaredOn: 'answer'. Only that member goes; the rest of the answer stays as the model wrote it, except that an object left empty goes whole (a code block of notes with its fences) when it stood on its own line — an answer that was only that one object stays {}, so a JSON answer stays JSON — and stays {} anywhere else; a _findings nested inside another object's value is left as data. The token stream (agentfootprint.stream.token) goes through the same step piece by piece, so it never shows notes it takes off, and the answer turn's tokens join to exactly the answer run() returns unless an output rule rewrites it afterwards.
Naming a result — the offer
On a hosted model the ask "by its tool_result id" produced standings named by ordinal — "0", "1" — which the ledger records honestly as unknownId and which settle nothing: a model does not copy a long opaque id out of prose, it counts. So from the second call on, the reserved _findings property on every served schema lists, in previous[].toolCallId, the ids the model may name — an enum of the tool results on the wire the model can still read, newest first, at most 32, and a clipped list says so in the property's description. A result with no standing, a fact and an open result stay listed, so a later call can revise a standing (open to fact once something settles it, fact to ruled-out when a conflict resolves — the ledger keeps the last standing per result); a result declared noise or ruled-out leaves the list, because it is a ticket on the wire and there is nothing left to re-judge. A result the window evicted is not on it, and the first call, with nothing to name, serves the base property unchanged. The offer is computed where the served history and the ledger meet, bound into the schema at the one decoration site and committed with the tool list, so servedAt(snapshot, k).tools.schemas carries exactly what was offered at call k and the receipt's schema hash moves when the offer does. What the model may copy is not what the library resolves: an id outside the offer is still filed as written, with unknownId: true, never mapped to a position or a tool name — and every id inside the offer resolves, because a standing is identified against the same served history the offer was read from, whichever call produced the result. A JSON answer has no schema to bind, so the instruction asks for the id exactly as the schema listed it, and standings declared there are counted apart.
Two things this costs. .findings() needs the tools slot rebuilt every call, so it is refused at build under reactMode: 'classic', which caches that slot after the first turn; use the default dynamic mode or dynamic-grouped. And because the enum moves with the run, an armed agent's tool schemas differ from call to call, so a prompt-cache breakpoint placed on the tools block cannot hit on such a run and the breakpoints after it miss with it; no number for that trade has been measured, and the design page names the alternative not taken.
{ "type": "string", "enum": ["toolu_01X…", "toolu_01W…"],
"description": "The tool_result id being judged — one of the ids listed; a result not listed cannot be named here." }What the call set out to test — proposition and predicts
A FindingsDeclaration may carry two optional one-line strings on the call itself, written before its result exists: proposition, what the call tests (the schema recommends it when basis is exploratory), and predicts, what the result should show if the proposition holds. Both land on the BasisRow, each cut at 240 characters with the cut stated in the text; the agentfootprint.findings.declared event carries hasProposition: true and never the text. On the served piece an open or ruled-out line quotes the judged call's own proposition after the model's words, so what was ruled out reads against what the model set out to test rather than against hindsight; a fact line does not repeat it, and predicts is record-only. Nothing is inferred: no proposition declared, no tested: on the line.
// The model's third call, as sent: a basis with a proposition, and the previous result's standing.
{ name: 'search_logs', args: { q: 'fc1/7', _findings: {
basis: 'exploratory',
proposition: 'the optic was swapped during the maintenance window',
predicts: 'a swap entry for fc1/7 in the change log',
previous: [{ toolCallId: 'toolu_01W…', standing: 'fact', assertions: [/* … */] }],
} } }
// …and the line the answer turn is later served, once the model rules it out:
// ruled out (search_logs, tool:toolu_01X…): no swap entry this week — tested: the optic was swapped during the maintenance windowWhat the answer turn is served
Once the model has declared at least one standing, every later call is served the ledger as a system piece composed from the record: the context contract's facts, limitations, evidenceRefs and nextSteps, each bucket marked as declared by the model and quoting the declaration, then noise as a count and the results nobody named listed as undeclared — never as open. On the wire, and only on the wire, a tool result the model judged noise or ruled-out is replaced by a short ticket, { collapsed: true, standing, toolCallId }, in place of its content. open and undeclared results are served in full, nothing is dropped or reordered, and history never changes, so the record still holds every result. servedAt(snapshot, k) rebuilds exactly what went out — the piece is the entry with source: 'findings' in system.pieces — and the receipt hashes it.
The serve option chooses how much of the pile stays beside the piece:
Agent.create({ provider, model })
.tool(search)
.findings({ serve: 'ledger-and-facts' }) // the default
.build();'ledger-and-facts'(default): facts stay verbatim; noise and ruled-out results become tickets.'ledger-only': fact results become tickets too and the model answers from the piece alone. This dial exists for the bench and is not a recommendation: it stays off by default until a run on a real model shows the answer does not drift when the same evidence arrives in a different order.
A second dial on the same door, answerAsk, appends a short ask to the end of the served piece under 'quote-facts' — answer from the facts lines and copy each value as written, name an evidenceRefs or nextSteps result as unsettled, do not draw on a collapsed ticket, never invent a value — and is bench-gated the same way: the default is 'none', which serves the piece exactly as before and writes nothing new to the record, while 'quote-facts' puts the run constant findingsAnswerAsk on the record so servedAt rebuilds the same bytes; it exists so npm run bench:findings:shuffle's ledger+ask row can score the ask on a real model against the one-fact-in-twelve fidelity dip the design page measured.
Held in the window — keepLedgerFacts
The piece restores a declared fact to the answer turn, but under a window strategy the fact's result still left by recency: the refusal engine saw a result the model had declared a fact and one it had declared noise as the same bytes. Since 9.102.0 the window reads the ledger. A turn whose result the model declared a fact is held beyond keepRecentTurns — newest first, up to keepLedgerFacts turns — and the record names the hold with the refusal reason ledger-fact. Noise, ruled-out, open and undeclared turns leave oldest-first exactly as before, so "noise leaves first" is the same rule seen from the other side. The hold lives in the refusal engine, not in a strategy: slidingWindow, tokenBudget, summarizeOldest and a strategy you wrote all get it through planRemoval, and none of the three shipped strategies changed a byte.
Agent.create({ provider, model })
.tool(search)
.window(slidingWindow({ keepRecentTurns: 6 }))
.findings({ keepLedgerFacts: 6 }) // default 4; `false` or 0 switches the hold off
.build();The same dial is Agent.create({ keepLedgerFacts }), beside keepLastToolResults; the .findings() door wins when both are given, a negative or non-integer value is refused at build, and without .findings() there is no ledger to hold from, so the option is accepted and does nothing. Four bounds compose, and none is optional:
- By the model's claim only. A turn is the removal unit, so its standing is its most valuable result's (
factoveropenover undeclared overruled-outovernoise): a parallel batch that answered one fact beside two noise results is one slot, and the noise stays with it. Nothing in the library reads a result's text to second-guess a standing; a result the model never named is undeclared and is not held. - The ceiling, spent newest first. A held turn already inside
keepRecentTurnscosts nothing; the rest are admitted newest first up to the limit, and the ones turned away are counted asyieldedon the record. - Anchored. Nothing at or before the current request is pinnable, so a new user turn releases the whole previous loop.
- The stand-down. If the two previous visits both removed nothing and both named only pins (
last-tool-resultand/orledger-fact), the fact pins release for one visit and the record says so. Both names count, because a turn held by both pins is reported under the recency pin's name — a fact stand-down reading only its own name would never see that turn blocking, and the two pins would take turns holding the window forever. A fact hold never exists without its ceiling and its stand-down.
Every visit's WindowRecord gains two optional keys, present only on an armed agent: ledgerFacts — what the hold kept (pinned: tool, turn, exact chars), how many turns the ceiling turned away (yielded), the limit, and standDown: true on the visit it released — and droppedStandings, the standing the model had declared for every tool result that left, by toolCallId, with standing absent for a result it never named. Both are filed by the stage, so a strategy you wrote carries them too, and an agent without .findings() files exactly the record it always did. The candidate a hold is built from is exported as LedgerFactPin — the tool-call ids a held turn carries, the tool it is named for, its turn and message index, and its exact chars — for a reader that wants to reason about what a visit could have held. What the model is told about a drop is unchanged: the notice names tools and counts, never a standing and never the model's own line.
Measured on npm run bench:findings (mock provider, 30 tool calls, a planted fact every third, a sliding window keeping 6 turns): the results of 2 of 10 planted facts reached the answer turn verbatim by recency alone, 6 under the default ceiling and 8 under a ceiling of 6, while the piece carried 9 of 10 on every armed row and the noise share of tool-result bytes on the wire stayed at the collapsed level; the design page has the print and its reading.
A second source — .findings({ judge })
The ledger's standing is the model's claim, and the real-model runs measured how often that claim was right: about nineteen times in twenty on the stronger model, less on the weaker one. A calibrated classifier can read the same result and say what it is worth, with a probability per standing instead of a word. Pass one as judge and, after every tool result lands and before the next model call, the agent spends one classifier call on it and files the answer as a JudgmentRow beside the model's own StandingRow: the judge's name and the provider's model string, against — proposition when the call declared one, question when it did not — the chosen standing, the whole distribution in probabilities, confidence, testsSubject (the provider's probability that the result tests the subject at all), usage, latencyMs, and clipped: true when the result text was cut at 4000 characters before the judge saw it. The judge is asked what the result is to the declared proposition or the user's question — never why the model called the tool.
import { typesafe } from 'agentfootprint/classify';
const agent = Agent.create({ provider, model })
.tool(search)
.findings({ judge: typesafe() }) // TYPESAFE_API_KEY, or `typesafe({ apiKey })`
.build();Two sources are two rows, never one. The library does not merge the judge's reading with the model's, does not default a standing from a probability, and in this release serves nothing from a judgment: the answer turn still reads the model's own standing, and a result the model called ruled-out that the judge called noise stays on the record as exactly that disagreement. A failed call — a rate limit, an outage, an answer outside the four standings — is a JudgmentErrorRow with the provider's status and message and the run continues; a judge that is not a Classifier is refused at build. The events agentfootprint.findings.judged (standing, confidence, latency, tokens) and agentfootprint.findings.judge_failed carry identities, enums and numbers only; agentfootprint.findings.standing gains agrees — the model's standing against the judge's, present once a judgment exists for the result. Only a result the tool produced is judged: a denied, halted or declined call spends no classifier call. Without judge, nothing runs and an armed agent records the bytes it recorded before.
The classifier itself lives on agentfootprint/classify: the Classifier port (name plus classify(request, signal?)), the request and answer shapes — ClassifyRequest, ClassifyQuestion as ClassifyChoiceQuestion, ClassifyNoulQuestion or ClassifyScoreQuestion, ClassifyResult, ClassifyAnswer as ClassifyChoiceAnswer, ClassifyNoulAnswer or ClassifyScoreAnswer — and ClassifierError with its status and retryable. Two adapters ship: typesafe(options) (TypesafeClassifierOptions: apiKey, model, baseUrl, timeout, maxRetries, retryDelayMs) for TypeSafe's System One classifier, which retries 429 and 529 with exponential backoff and never puts the key in an error, and mockClassifier(script) (MockClassifierScript, a MockClassifier with every request on calls) for tests and the bench. The same port ranks a skill graph's entries: classifierScorer(classifier) on agentfootprint/skill-graph (options ClassifierScorerOptions) is an EntryScorer whose ranking is the provider's distribution — one choice question under the id CLASSIFIER_SCORER_QUESTION, the provider's pick as chosen, and on a provider failure the scorer name CLASSIFIER_SCORER_UNAVAILABLE with an empty ranking, so the cold-start entry pick takes over rather than a guess.
npm run bench:findings:shuffle measures it under AF_SHUFFLE_JUDGE=mock|typesafe — judge-accuracy against the planted truth, judge-agrees against the model's own standing, judge-tokens and judge-latency-ms read off the rows — and the design page (docs/design/2026-09-findings-ledger.md in the repository) names the serving policies the bench will decide between.
No towers on unverified lemmas — contingent values
The ledger says what the model declared about each result; the evidence gate says which result carried each value the model states. Joined, they answer a question neither can alone: did the model use a value it had itself set aside? A reading taken from a result the model later called noise, a port id from a result it ruled-out, an argument built on an open lead — each is a theorem on an unverified lemma, and in the answer it reads exactly like a fact. With both doors armed, .findings() beside .namesAndNumbersFromEvidence(), the library records it: a value the model uses — in its final answer, or as an argument of a later tool call — whose every carrier is a result the model declared open, noise or ruled-out lands on the ledger as a ContingentRow (declaredOn, the normalized value, every ContingentCarrier with its standing, iteration), one row per value per moment, through the same writer as every other row. Nothing is inferred and no judge is called: the standings are the model's own declarations, the last one per result being current, and the carriers are the evidence corpus's provenance — EvidenceCorpus.carriers, the ids of the current turn's tool results that carried each value, at most eight per value and marked truncated past that, in which case the value is not judged. A carrier with no declared standing means the value stands; so does one fact carrier among several, or an undeclared carrier beside a set-aside one. Nothing is judged from a corpus whose token ceiling was hit — a fact carrier past the cut is invisible, and the gate already downgrades itself under that flag. The carriers are the current turn's: in a continued conversation, a value taken from an earlier turn's result is not judged and gets no mark. A value the person or the app supplied is never contingent. Which tokens count as values is the gate's own rule, so a word is never a tower and an all-letters name needs the shape the gate would need; two spellings of one value file one row.
const agent = Agent.create({ provider, model })
.tool(probe)
.findings()
.namesAndNumbersFromEvidence({ posture: 'assist' })
.build();
await agent.run({ message: 'which port is down?' });
agent.findings()?.filter((row) => row.kind === 'contingent');
// [{ kind: 'contingent', declaredOn: { toolCallId: 'c2' }, value: 'fc1/7',
// carriers: [{ toolCallId: 'c1', standing: 'ruled-out' }], iteration: 2 }]Two moments file the row. At the answer, after the gate's verdict, over the values the gate found and a result carried (EvidenceVerdict.grounded, each with the spellings it was looked up under); the row says declaredOn: 'answer'. At dispatch, in the tool-calls stage, after a call's basis row and before it runs, over the data values of its arguments; the row names the call, and because every standing in the batch's _findings.previous is filed before the check, a standing the model declared on that call or on a sibling call of the same batch governs the call's arguments. Both are detection only — no branch changes, no call is blocked, the answer goes out as written. Each row fires agentfootprint.findings.contingent with the moment, the carrier count, the distinct standings and the value's length, never the value. The next model call is served the towers back in the ledger piece, under the heading contingent (read off the record): after the four buckets — named as the library's join, so the piece's header about what the model itself declared stays true — — tool:c2 used fc1/7 from tool:c1 (ruled-out), or answer used … on a re-ask — so the one call that can re-establish a value sees which value needs it; servedAt rebuilds the line from the record. The instruction gains one line under both doors, and only then: a value taken from a result declared open, noise or ruled out is recorded as contingent; either re-establish it from a result the model stands on, or say the answer is contingent on it. An agent with one door or neither files no row, emits no event and records the bytes it always did.
Cache reads are a cost the same record now carries: AgentState.totalCacheReadTokens sums usage.cacheRead over the run's model calls beside totalInputTokens, written only once a provider has reported one, so a provider that reports none (the mock, a local model) never writes the key. npm run bench:findings:shuffle reads three new columns off the record — contingent (rows per run, the tower rate a hosted run measures), cache-read and cached % (summed off the llm_end payloads, — when nothing reported cache reads) — and on the mock a fifth condition, ledger+tower, scripts an answer that quotes a value from a result it declared noise, so the column is proved to read 1 there and 0 on the clean rows.
Nothing found is not ruled out — unsettled-by-absence
A lookup that matched nothing witnesses no negative. When the model declares a result ruled-out and the tool returned an absent() envelope for it — bare or bounded by coverage(), which the dispatch door records as an absence (the delivered status 'absent', the tools.absent event, a coverageDeclared row) — and the model was served that envelope, the ruling-out rests on "nothing found", and the envelope usually says what the search never checked. The model's standing is its own word and stays exactly as declared. Beside it, .findings() files an UnsettledByAbsenceRow: the toolCallId of the ruled-out result, the notChecked and cannotCover lists of the envelope as the model was served it, and its try_instead byte for byte (tryInstead, quoted and never parsed — the sentence alone: a typed try_instead_tool beside it is not copied, since nothing reads a typed tool off the row). The row lands immediately after the standing, at both moments that file standings. Whether the tool returned an absence is the door's call alone, never a second reading: an envelope a tool returned as text (an MCP server's default text mode; mcpClient({ resultMode: 'structured' | 'structured-or-json' }) delivers it as an object) files nothing, and so do a zero-row array, a bare { hba_count: 0, hbas: [] } wrapper, prose, an open, noise or fact standing, and a ruling-out on a call its paused batch never dispatched (the resume's settlement is no result, and that call never reached the door). And the model rules out on what it read: the door records a return before the after-tool chain, the tool's own ceiling and placement act, so a result an after-tool deny withheld, a ceiling's refusal, a placed ticket or a summary files nothing, and an after-tool scrub is honored — the row never quotes a word the model was not served.
const agent = Agent.create({ provider, model })
.tool(hostHbas) // returns absent({ …, notChecked, cannotCover, tryInstead }) on a miss
.tool(clusterInventory)
.findings()
.build();
await agent.run({ message: 'is nas-cluster-06 slow?' });
agent.findings()?.filter((row) => row.kind === 'unsettled-by-absence');
// [{ kind: 'unsettled-by-absence', toolCallId: 'c1',
// notChecked: [{ what: 'whether nas-cluster-06 is a hypervisor host at all', why: '…' }],
// cannotCover: [{ what: 'HBAs on hosts outside the collected inventory', why: '…' }],
// tryInstead: 'Look nas-cluster-06 up in cluster_inventory; a storage cluster has no host HBAs.',
// iteration: 2 }]From the next call on, a ruled-out result is a ticket on the wire, so the row is how the envelope's boundary still reaches the model: the ledger piece serves it under unsettled by absence (read off the record): after the contingent section — ruled out (host_hbas, tool:c1) on an absence, then one line per part of the envelope, tool:c1 not_checked: …, tool:c1 cannot_cover: …, tool:c1 try_instead: …. The model's own ruled-out line stays in limitations, quoted as declared, and a row is served only while that result's current standing is still ruled-out — in practice for the rest of the run, since a ruled-out result leaves the offer. No event and no instruction line ship with it, and the door's coverage rows are read only for a ruling-out on a result served as an absence, so an agent whose model never rules out such a result records the bytes it always did; the checkpoint carries the row into a continued conversation.
Tool choice by classifier — .toolChoice()
The loop's most frequent choice is which tool, and the model makes it by generating a call — the record holds the call and nothing about the choice. .toolChoice({ classifier }) asks the same classifier port a second reading of every one of those choices: at each model call the tools slot puts one choice question to it, under the fixed id TOOL_CHOICE_QUESTION, whose criteria are the tools about to be served — the merged wire minus the always-served doors, each by its own description — over a state of the user's message and the active skill id. The answer lands on AgentState.toolChoices as a ToolChoiceRow before the call: offered, ranked (a ToolChoiceScore per name the provider scored, highest first, an unscored tool absent rather than zero), chosen (the provider's own pick, absent when it named nothing offered), confidence, usage, latencyMs, and served with narrowed. After the reply the call-llm stage files a ToolChoiceOutcomeRow: called (the model's tool calls in order, empty on an answer), firstAgrees (whether the first call is the pick, absent when either side is absent) and any miss. A failed classifier call is a ToolChoiceErrorRow with the provider's status and message, and the full set is served. The three row kinds are ToolChoiceEntry; the key is a ToolChoiceLedger — flat, append-only, a fresh array per write.
import { typesafe } from 'agentfootprint/classify';
const agent = Agent.create({ provider, model })
.tool(lookupOrder)
.tool(refundCharge)
.tool(listInvoices)
.toolChoice({ classifier: typesafe(), serve: { top: 2 } })
.build();
await agent.run({ message: 'refund order 42' });
agent.getSnapshot()?.sharedState.toolChoices; // pick → served → called, per callThe model's call is never overridden and, by default (serve: 'all'), nothing is served differently: an advisory agent's requests are byte for byte the unarmed agent's, and the record gains the rows and nothing else. serve: { top: N } is the narrowing dial: the tools slot commits the classifier's top-N plus the doors as the served list, at the one site where the served list is built, so the request, the receipt's tools.schemaHashes and servedAt(k).tools.schemas all name the narrowed list by construction. The doors are never narrowed away and never offered as candidates: read_skill, list_skills, skip_step and present — the constant ALWAYS_SERVED_TOOLS — plus every name in alwaysServe. The full wire is served, with the reason on the row as a NarrowSkipReason, when the classifier failed or scored fewer than N names (unavailable), fewer than N + 1 candidates were offered (too-few), the previous call's outcome carried a miss (after-miss), or the call is the out-of-budget wrap-up (wrap-up). A miss is a fact, not a refusal: when a narrowed call's reply names a tool outside served, the outcome row carries miss.wanted, the dispatcher answers the call exactly as it answered an off-wire call before (agentfootprint.tools.answered_off_wire), and the next call serves the full wire. reactMode: 'classic' is refused at build, as it is for .findings().
The events carry identities, enums and numbers only: agentfootprint.tool_choice.picked (the pick, its confidence, how many tools were offered and served, whether the list was narrowed and why not, latency and tokens), agentfootprint.tool_choice.outcome (called, firstAgrees, missed) and agentfootprint.tool_choice.failed (status, latency) — never a description, never the message. npm run bench:tool-choice measures both dials on the mock provider from the record alone — first-agrees, misses, extra-calls, tools-slot-bytes from the receipt's requestMeasurement, pick-tokens, pick-latency-ms — and exits non-zero if an unarmed twin's tools-slot bytes move; the design page (docs/design/2026-09-scored-choice.md in the repository, step 4) has the print and its reading, and names what the state does not yet carry (the conversation so far).
The ontology — a map, not a door — .ontology()
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. It is a map, and only a map: it does not provide a way to get data, nothing is executed or fetched through it, and the library infers nothing from it — it tells the model about each node and how a node is reached, so a model that finds no data can say which node or source would help further.
defineOntology (on agentfootprint/ontology) takes an OntologySpec — id, version, nodes (an OntologyNode per term: meaning, unit, aliases, and its sources, each an OntologyNodeSource naming the source, the tools that read the term from it (via) and a coverage sentence), sources (an OntologySource per place data is held: meaning, coverage, and configured, a boolean the author wrote or absent — absent means unknown, never assumed) and edges (an OntologyEdge per relation: from, to, the author's relation word, an optional meaning) — and returns an Ontology: validated, detached, deep-frozen, with edges always present and a hash. Every fault is refused by name at definition: an id that is not identifier-safe, an edge to a node nobody declared, a node held by a source nobody declared, an empty or over-long text, a repeated alias, via or edge, or a count past ONTOLOGY_LIMITS. A node with no source is legal — known, nowhere collected here. ontologyHash is the declaration's fingerprint: SHA-256 over the canonical JSON of the five declared fields (the receipt's own stableJson, so key order never moves it; edge order is part of the identity).
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'], coverage: 'every port on every switch' }],
},
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();
await agent.run({ message: 'what is the error rate on fc1/7?' });
agent.getSnapshot()?.sharedState.ontology; // { id, version, hash, spec } — the run constant.ontology(map) (AgentOptions.ontology) is the one door and refuses a second call. At .build() every tool name a node's via names is checked against the agent's tool registry — a .tool() name, a skill's tool, the framework's doors — and a name no registry carries is refused, naming the ontology, the tool, the node and the source. At run seed writes the whole map ONCE as the run constant AgentState.ontology, an OntologyRecord — id, version, hash and the whole spec — so the served text, the rebuild and a lens need nothing but the record. Every model call is then served ONE request-only system piece, composed by the pure ontologyPiece (an OntologyPiece with source: 'ontology', a new ContextSource): a constant header quoting the contract's domainDefinitions, limitations and evidenceRefs meanings, then nodes: (<id> — <meaning> (<unit>) · aliases: …, sorted by id), sources: (<id> — <meaning> · coverage: … · configured: yes|no — an absent configured says nothing), held by: (<node> ← <source> via <tool, …> · <coverage>), relations: (<from> —<relation>→ <to> · <meaning>, in declaration order) and known, not held here: (the nodes with no declared source, as an id list). Every line quotes the declaration; no sentence the library wrote about the domain. Sections cap at ONTOLOGY_PIECE_LIMITS lines with the overflow stated, an empty section is omitted, and the piece carries no per-call byte, so an unchanged map serves unchanged bytes and reuses the cached system prefix. It is joined after the recovery piece and before the findings piece (injections → recovery → ontology → findings, fixed), never as an injection; the receipt hashes it per request and servedAt rebuilds it byte-equal from the record — the conformance law holds at every epoch, in both chart shapes.
The ask the model reads is a named value, OntologyAsk — .ontology(map, { ask }), or ontologyAsk beside ontology in Agent.create (refused without the map). 'use-the-map', the default, registers the always-on instruction ONTOLOGY_INSTRUCTION (under ONTOLOGY_INSTRUCTION_ID, the outputSchema() twin): read the question in the map's terms and aliases; where a term is held by a declared source, the tool named beside it is where to look, and a relation is the way from a term already held to the term needed; when the results did not meet a need, say which declared source, tool or neighbouring term the map names for it — as a proposal, never as a claim that data exists there — never invent a value from the map, report a term known but held nowhere here as declared, and speak to the person of sources, tools and terms, never of the map. 'none' serves the same piece on every call and registers no ask of the library's, for an application that writes its own through .instruction(); the record shows which ran, as the ontology injection present or absent.
The map is joined to the skills (9.108.0). At build, beside the via check, the agent reads the registry's toolDeclaringSkills for every tool the map names and writes AgentState.ontology.tools — tool → the ids of the skills that declare it, present only when some via name is a skill's. The piece prints the join beside the tool (via vm_backup_status [skill: backup-check], [skills: a, b] when shared), and the instruction says that skill id is what read_skill takes, so a model that finds a term held one skill away no longer guesses the skill from the catalog. ontologyPiece takes an OntologyJoin — the record's tools and the request's hiddenSkillIds — and applies the same hidden-skill law as every other model-facing sentence: a hidden skill's id is omitted, a tool every declaring skill of which is hidden is omitted whole, a static tool is never filtered; the served view rebuilds from the record and the epoch's hidden ids, so the receipt agrees under a role that sees less.
How a "no data" answer is measured (9.109.0): scoreAbsence(spec, turn, expected) on agentfootprint/ontology scores one turn off the record and the declaration alone — an AbsenceTurn (the answer, the tool names called, the library's own unsupportedValues count when the record carries one) against an AbsenceExpectation (the term or source id the bench author declared the question turns on, before the run) — and returns an AbsenceScore: whether the answer named the gap (its id or a declared alias, whole-word, _ standing for a space, a plural allowed), whether it named a declared neighbour (a holding source, a reading tool, a term one relation away; undefined when the map declares none), the neighbours it named, the tool-call and unsupported-value counts, and the map words it used on the person (ontology and its forms, map/maps). summarizeAbsence(scores) tallies an AbsenceSummary, k of n per check over the turns that had it. Nothing fuzzier than declared strings — which is why an OntologySource may now declare aliases (served beside its meaning, validated like a node's): the declaration must carry the words people use. An expectation naming a gap the map does not declare is refused. The event agentfootprint.ontology.served fires once per call that served the piece, with the map's id, version and hash and its node, source and edge counts — identities and numbers only (Payloads.OntologyServedPayload); subscribe to the domain with agentfootprint.ontology.*. An agent without .ontology() is byte-identical to one built before the map existed: no key, no piece, no instruction, no event. What the map is measured on — for questions whose data is not collected, how many tool calls precede the honest answer and whether that answer names the source — is a bench on the first host after the release; the design page (docs/design/2026-09-ontology.md in the repository) says what it measures and what it does not.
Bringing your own taxonomy (9.112.0): fromSkos(input, join) on agentfootprint/ontology reads a SKOS concept scheme in JSON-LD — a SkosInput, already parsed: a @graph, a flat array of node objects or one node; full IRIs, the skos: prefix, or bare keys under the document's @context — and returns an OntologySpec for defineOntology, which stays the one shape everything reads. Nothing is inferred: a concept's id is the last segment of its IRI (lower-cased, - and space to _), its meaning is skos:definition, else skos:scopeNote, else the prefLabel, its aliases are every altLabel and hiddenLabel in the asked language (join.language, default 'en') plus the prefLabel; skos:broader and skos:narrower become one is-a edge per pair, skos:related one related edge per pair; the scheme node's last IRI segment and its dcterms:modified, owl:versionInfo or schema:version give the id and version unless join.id and join.version name them. SKOS cannot say which source holds a term, which tool reads it, its unit or a coverage sentence, so the host binds those by hand — SkosJoin.sources and bind (term id → its OntologyNodeSource[]); a term with no binding has no sources. Every refusal is a SkosError with a code (SkosErrorCode, ERR_SKOS_…), the IRI(s) involved and what was expected — no prefLabel in the language, two concepts collapsing to one id, a relation to a concept the document does not hold, a broader cycle, a missing id or version, a bind key the scheme does not hold — and the reader never returns a partial map. readSkos(input, options) (ReadSkosOptions: the language) is the pure parse (a SkosScheme: the concepts as SkosConcept rows sorted by id, the edges sorted, the scheme's identity) for a host that wants to look before it joins; toSkos(spec, options) (ToSkosOptions: the language its labels are tagged with) is the reverse walk to a SkosDocument whose @context declares a footprint: namespace (FOOTPRINT_NS, beside SKOS_NS) for unit, sources, via, coverage and any other relation, so nothing is lost — an export for a host that keeps its taxonomy in SKOS, minted under SKOS_IRI_BASE; the model is still served ontologyPiece. A Turtle parser and OWL are out of scope.
Composition with other Agents
An Agent is a runner, just like LLMCall, Sequence, Parallel, Conditional. Use them as steps in larger graphs:
import { Sequence } from 'agentfootprint';
const research = Sequence.create()
.step('plan', plannerLLM)
.step('execute', researchAgent)
.step('summarize', summarizerLLM)
.build();Pass the identity to the composition. A composition runs its members in its own executor, so a member never sees the identity you would have passed to its own run(). Hand it to the composition instead; every member — at any depth of nesting — runs for it, its memory and its tools' ctx.identity included:
await research.run({
message: 'Compare the two refund policies',
identity: { tenant: 'acme', principal: 'alice', conversationId: 'thread-42' },
});Without one, every member runs under that composition run's own conversation — never a namespace another run shares. Two users running the same composition at the same time never see each other's memory. An agent mounted in a chart you built yourself must be handed identity by your input mapper; one that no run reaches is refused with MemberIdentityError rather than run under its instance's last run. A member reads its run only through the link its own composition opened: a parentRun the engine did not issue (a hand-built one naming another user, or one whose run has ended) is refused with ParentRunRefusedError, so no input can pass a member someone else's identity.
There is no separate "multi-agent" class — agents are the building blocks. See Patterns for Reflexion / ToT / Debate / Map-Reduce / Swarm recipes.
Anti-patterns
- Don't subclass Agent for a "smarter" agent — use
.skill()/.instruction()to inject behavior. Subclassing breaks composition. - Don't put async I/O in tool
execute's synchronous setup — it runs every iteration. Cache outside; pure dispatch inside. - Don't bypass
maxIterationsto "let the agent decide" — every loop has a cost ceiling. Set it explicitly; observeagentfootprint.agent.iteration_endto track.
Next steps
- Tools guide —
defineTool, MCP integration, permission gating - Memory guide —
.memory(defineMemory({...}))for cross-run state - Skills, explained — LLM-activated body + tools
vs Other Frameworks
How agentfootprint sits relative to LangChain, LangGraph, CrewAI, AutoGen, Mastra, Genkit, Pydantic AI, DSPy, and Inngest AgentKit. We didn't have to choose between them.
Recipes — an agent setup you can name
defineAgentRecipe declares an agent's whole setup as one named, versioned AgentRecipe, and .recipe() applies it. Every run then says on its manifest which composition produced the agent that answered.
