Skip to content

Writing Tools

Joey King edited this page Oct 3, 2026 · 3 revisions

Writing Tools

A tool is the only way an agent acts. Its specification is what the policy reasons about, so the shape of a tool decides how much the policy can see.

type Tool interface {
    Spec() ToolSpec
    Call(ctx context.Context, call ToolCall) (ToolResult, error)
}

The specification

Field Rule
Name non-empty and unique within the driver
Description for the model; keep it stable, it is part of every request
InputSchema JSON Schema, self-contained; use additionalProperties: false so unknown arguments are rejected
SideEffect read_only, local_mutation, remote_mutation, or destructive. It also decides what happens after a crash: only a read_only call is let continue without a reconciliation or an operator
Timeout required; the call is cancelled and the timeout becomes an observation
Terminal success completes the run with the result

NewDriver refuses a tool with an empty or duplicate name, an invalid side-effect class, a zero timeout, or a schema that is missing, is not valid JSON, is nested deeper than 256 levels, refers to anything outside itself, or does not compile.

Schema references

A tool's input schema is compiled from itself and the standard JSON Schema metaschemas only. References inside the document ("$ref": "#/$defs/path") resolve as usual, and a $schema naming a standard draft is fine, because the compiler carries those metaschemas. But a $ref, $schema, or $id naming a file or a URL fails registration with an error saying the schema refers outside itself; nothing is read or fetched. The compiler's default loader would otherwise read any file: URL, so a schema could read the operator's files or block on a named pipe. Inline what you referenced, or put it under $defs.

The depth bound applies to the schema too, because the schema is part of every approval request the tool is named in, and that request must hash.

Design rules

Expose the thing policy needs to decide as an argument. A write_file tool takes a path, so policy can match protected paths and compute the projected diff. A run_command tool takes a string, and policy can say nothing. Prefer many narrow tools to one wide one.

Contain inside the tool as well. Policy is the second line; the tool is the first. A file tool should refuse traversal, symlinks, and escapes itself, so that a policy bug does not become a breach.

Put nothing run-specific in results. Run ids, absolute paths, and timestamps in tool results make every model request unique, which breaks recording and replay and makes runs incomparable.

Return errors for tool failures, not results describing failures. An error becomes a tool_error observation, counts toward the consecutive-failure limit, and is visible to the policy as a failed step. A result that says "failed" inside its content is invisible to both.

Journal side effects, and expect to be asked again. After a crash, a call that was executing a tool that is not read_only is re-run only if the consumer's reconciliation says so or an operator approves an interrupted_side_effect, and then it is the same request, on the same step. A tool that can recognize its own earlier attempt, by a deterministic id or a marker in the outside system, turns that re-run into an answer from its record instead of a second effect. See Interruption and Reconciliation.

Use ErrAbortRun for conditions that make continuing pointless. For example, a frozen artifact found invalid at publication. The run ends FAILED with reason tool_abort and the detail, in the same transaction that ends the step. This is unchanged in v0.3.

Mark terminal tools. A tool that finishes the task should be terminal so the agent cannot keep acting after success.

Register conditionally rather than changing shapes. If a tool is only sometimes available, add or omit it; do not change its description or schema between runs.

Validating arguments in a test

A tool's schema is only as good as the arguments it was tried against, and since v0.3.1 the runtime's own acceptance of a tool call is available without a driver or a store:

ts, err := agentrt.CompileTool(spec)     // the checks NewDriver makes of a spec, and its schema compiled once
err = ts.Check(json.RawMessage(args))    // the runtime's acceptance of these arguments
err = agentrt.CheckArgs(spec, args)      // both in one call

CompileTool checks a spec as NewDriver checks each tool, a name, a valid side effect, a timeout, and an input schema that compiles from itself alone, and returns it compiled; its error is the one NewDriver returns for the same spec. ToolSchema.Check applies the JSON boundary checks a decision's arguments meet (depth, UTF-8, escaped surrogates, repeated keys) and then the input schema, with empty arguments meaning {}, and its error text is the one the driver records in an invalid_decision observation for the same arguments. A ToolSchema is safe for concurrent use. CheckArgs is the two in one call, and keeps the compiled schemas of the last few specs it was given so a loop over one spec does not recompile; a caller holding many specs keeps CompileTool's results. NewDriver and the loop go through the same code, so a test that passes here is not a test of a copy.

That is the building block. Testing a Consumer is the rest: testkit.Fuzz generates valid arguments and near misses from the schema and checks that valid ones reach the tool without a panic and invalid ones are refused, and testkit.Run crashes a run around the tool's effect to see what the runtime does about it.

Identity

ToolCall carries the run id and step id the driver assigned, so a tool can journal its own work against them. It is the only place a tool learns which run it serves.

What the runtime refuses at the boundary

The side effect has happened by the time Call returns, so its record must survive whatever the tool returned. ToolResult.Content must be usable JSON (one well-formed value, valid UTF-8, nested no deeper than 256 levels, no repeated object key, no escaped surrogate outside a pair), because the content is stored and hashed, and anything else would be stored or hashed lossily. Content that fails is not dropped and does not panic: the step ends with a tool_error observation whose content keeps the bytes as a string (base64 under content_base64 when they are not UTF-8) beside the reason, with "failure": "invalid_content". It counts toward the consecutive-failure limit like any tool error. Empty content becomes {}.

Panics, timeouts, and cancellation

A panic inside Call is recovered and becomes a tool_error observation. A timeout cancels the context and becomes a tool_error with "failure": "timeout". Neither ends the run by itself; the consecutive-failure limit does if they repeat.

A tool that fails because the caller's context was cancelled is observed as interrupted, not as an error, because its outcome is unknown. Its observation is still recorded, on a context that outlives the cancellation, and the cancellation is returned afterwards.

A tool keeps running if its run is cancelled by an operator or its process loses the run's lease: the runtime does not recall a call in flight. After an operator's cancel its outcome is appended to the run as a late step.tool_finished event; after a lost lease it is not recorded, and the new owner treats the step as interrupted.

Clone this wiki locally