M3.1
The tool-calling contract, end to end
1,137 words · about 7 min
Concept
"Tool calling" names a four-stage contract, and every stage is a place a harness can be wrong. The stages are advertisement (the harness declares what tools exist), emission (the model produces a call), dispatch (the harness resolves, authorizes, and runs it), and return (the harness converts the outcome into something appended to the conversation). The model participates in exactly one of these four stages. The other three are entirely the harness's responsibility, which is why "the model is bad at tool use" is usually a misdiagnosis of a harness that advertised badly or returned badly.
The single most important property of the contract is that it is not a function call. It is a message exchange with an unreliable, non-deterministic peer that has no type checker, cannot be given a stack trace, and whose only debugging channel is the text you send back. Design accordingly.
Mechanism
Advertisement. On every request, the harness serializes the visible tool set
into the provider's function-calling field. In dsh this is the tools array on
GenerateOptions (packages/llm/llm/src/types.ts), where each entry carries
{ name, description, parameters } and parameters is a JSON Schema object.
Crucially, this projection is an explicit allowlist, not a serialization of
the whole tool object. The registry's schemas() method builds the model-facing
ToolSchema[] and the fields output, execute, finalizeContent, timeoutMs,
isConcurrencySafe, presentCall, and presentResult must never leak into a
model request (packages/core/tools/src/index.ts). A harness that naively
JSON.stringifys its tool definitions ships executable callbacks and host-only
scheduling metadata to the provider — at best wasted tokens, at worst an
information leak about internal policy.
Emission. The model emits a tool-call block containing a call id, a name, and
an arguments payload that is a raw JSON string, not a parsed object.
Module 2 established that this string stays raw end to end through the block
assembler (packages/llm/llm/src/assembler.ts), and that the loop parses it
defensively:
// packages/core/agent-loop/src/tool-calls.ts
function parseArguments(raw: string): unknown {
try { return raw ? JSON.parse(raw) : {} } catch { return raw }
}Note what this does not do: it does not throw. Unparseable arguments become the
raw string and flow onward to be rejected by schema validation with a message the
model can read. An empty string becomes {}, because models routinely emit no
arguments for a zero-parameter tool. This is the first appearance of the module's
governing principle — every malformed input becomes a model-readable error, never
a harness crash.
Dispatch. The loop hands the parsed arguments to the registry via
ctx.tools.execute(exec), supplying a ToolExecutionInput:
{ callId, name, arguments, signal, agent?, parent? }. The registry then owns
everything: it materializes the arguments as detached lossless JSON in one
recursive pass, freezes that value before any policy runs, assigns an opaque
ToolExecutionToken, and runs the full pipeline. Identity is immutable from that
moment — callId, name, arguments, and agent cannot be rewritten by any
listener, because history, audit, UI, and execution must agree on what was called.
Return. The outcome is a ToolExecutionResult, a discriminated union:
// packages/core/tools/src/index.ts (type-equivalent)
interface ToolExecutionSuccess {
readonly isError: false
readonly value: JsonValue // execution-local canonical value
readonly content: ContentBlock[] // what the model sees
readonly meta?: JsonValue
readonly additionalContexts?: UserMessage[]
readonly concludesTurn?: true
}
interface ToolExecutionFailure {
readonly isError: true
readonly error: ToolFailure // { message, info?: { name, code } }
readonly content: ContentBlock[]
readonly value?: never
}The split between value and content is the design decision most harnesses get
wrong. value is the canonical typed JSON the tool actually produced;
content is the rendered prose the model reads. They are produced by
different code (execute returns the value; a pure output.render(args, value)
projects the content) and they have different lifetimes — value is
execution-local and is deliberately omitted from durable events, while content
persists. Keeping them separate is what lets the same tool serve both a model
reading English and a program reading fields, without either one parsing the
other's format.
Worked example — tracing one `read` call
Worked example- Assembly:
schemas(agent)emits{ name: 'read', description: 'Read a UTF-8 text file and return line-numbered content.', parameters: { type: 'object', properties: { file_path, offset, limit }, required: ['file_path'] } }. - Model emits:
{ id: 'call_1', name: 'read', arguments: '{"file_path":"/src/a.ts","offset":1}' }. - Loop parses to
{ file_path: '/src/a.ts', offset: 1 }, callsctx.tools.execute({ callId: 'call_1', name: 'read', arguments, signal, agent }). - Registry freezes args, assigns token, runs
tools/pre-execute→ guards →tools/execute→ body. - Body returns a canonical value (the read outcome: offset, lines, totalLines).
- Registry validates the value against
output.schema, freezes it, callsrender(args, value)to produce content. tools/post-execute→finalizeContent→tools/result→ the loop appends a durabletool/resultsession event.- The loop appends a user-role message carrying a
tool-resultblock, perpackages/llm/llm/src/message.ts.
Step 8 carries the security consequence Module 2 flagged and this module
operationalizes: tool output speaks in the user's voice. The role union is
exactly 'system' | 'user' | 'assistant'; there is no privileged tool role. Every
byte a tool returns is, to the model, indistinguishable in authority from
something the human typed. That is the prompt-injection vector, and it is why
result shaping (M3.5) is a security control and not merely a token optimization.
Tradeoffs
Advertising every tool on every request costs a fixed per-request token budget proportional to the visible set, and — more expensively — it anchors the KV cache prefix. The tool block sits near the front of the request, so registering, disposing, or restricting a tool invalidates cache reuse from the first changed schema token onward. A harness that dynamically adjusts its tool list per turn to "save tokens" can easily spend more on cache misses than it saves on schema bytes.
How `dsh` does it
How dsh does itdsh treats the registry as a service (ctx.tools, a ToolRuntime) rather than
a data structure. Registration is effect-based: ctx.tools.register(definition)
returns the exact disposer, and disposing the owning plugin fiber unregisters the
tool. Schemas flow into system-prompt assembly automatically via
ctx.systemPrompt.tools(). Registration is scope-aware — a plain plugin context
registers globally, while an agent's own agent.ctx registers for that agent
alone and shadows a same-named global.
How others differ
Claude Code ships roughly 45 built-in tools and addresses the prefix-stability
problem directly with deferred tool loading: rarely-used tools are named but
not schematized in the prompt, and a ToolSearch tool fetches full schemas on
demand. This keeps the cached prefix stable across sessions that use different
tool subsets. OpenHands takes the opposite route with CodeAct: rather than
advertising many rigid JSON tool schemas, the agent expresses actions as
executable code, trading schema-level validation for the expressiveness of a
programming language. dsh supports both stances — its mode config selects
native, code, or both, where code contributes a single reserved run_code
transport instead of individual function schemas.
Check yourself
2 questions on M3.1