Certified Agent Harness Engineer
Course sections

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
  1. 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'] } }.
  2. Model emits: { id: 'call_1', name: 'read', arguments: '{"file_path":"/src/a.ts","offset":1}' }.
  3. Loop parses to { file_path: '/src/a.ts', offset: 1 }, calls ctx.tools.execute({ callId: 'call_1', name: 'read', arguments, signal, agent }).
  4. Registry freezes args, assigns token, runs tools/pre-execute → guards → tools/execute → body.
  5. Body returns a canonical value (the read outcome: offset, lines, totalLines).
  6. Registry validates the value against output.schema, freezes it, calls render(args, value) to produce content.
  7. tools/post-execute → finalizeContent → tools/result → the loop appends a durable tool/result session event.
  8. The loop appends a user-role message carrying a tool-result block, per packages/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 it

dsh 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

Start drill