11 KiB
Cookbook: adding a tool
How to give the model a new capability. Reference implementations: examples/echo-agent/src/echo-tool.ts (minimal) and packages/bash/tool-bash (production-grade, three-package seam).
The minimal shape
import { readFile } from 'node:fs/promises'
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'my-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'read_file',
description: 'Read a file from disk.', // what the model sees
parameters: {
path: { type: 'string', required: true, description: 'Absolute path' },
limit: { type: 'number' }, // optional by default
},
async execute(args, exec) {
// args is TYPED from the schema: { path: string; limit?: number }
// exec carries immutable identity + token; signal is the operational field
return [{ type: 'text', text: await readFile(args.path, 'utf8') }]
},
}))
}
Registration is effect-based: disposing the plugin fiber unregisters the tool (write the HMR test). Schemas flow into the system-prompt assembly automatically.
Rules of the execute() contract
- Args are validated for you.
defineToolvalidates the model-generatedargumentsagainst theSchemaSpecbeforeexecuteruns (type, required keys, enum membership, nested objects/arrays — runtime arg validation), so insideexecutethe args already matchInferArgs. You still hand-check value constraints the DSL can't express (non-empty strings, positive numbers, cross-field rules); throw a descriptive Error for those. Raw JSON-Schema tools registered directly (MCP) are NOT validated by the harness — they validate their own input. - Registration borrows your readonly definition. A typed same-process contribution is not a serialization boundary; do not mutate its schema or replace callbacks after registration.
schemas()materializes only the explicit model-facing projection. To hot-swap a tool, dispose its owning effect and register the replacement; mutable state inside the callback's closure remains ordinary plugin state. - Execution identity is protected. The registry materializes
argumentsas detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaqueexec.token;callId,name,arguments,agent,token, and an optional enclosing-transportparenttoken stay immutable through dispatch.parentis identity-only and exposes no live outer execution. Treatargsas readonly input. An around-dispatch wrapper may add, replace, or remove onlyexec.signalto impose cancellation or a deadline. - Throwing or returning non-JSON data means isError. The registry catches anything
execute()throws and materializes the complete post-policy result as lossless JSON before final observers run. A throw, malformed result, or non-JSON content/context/meta becomes{isError: true}so the live outcome cannot succeed and then fail at the durable log. Use errors for infrastructure failures (bad input, spawn errors, aborts), but report domain failures in the result text instead (for example, tool-bash returns[exit code: 9]withisError: falsebecause the model decides what a failing command means). - Honor
exec.signal. Cancel in-flight work when it fires. - Attach durable card data with
meta(optional).executemay return{ content, meta }instead of a bareContentBlock[]—metais a JSON-serializable payload the core treats as opaque, persisted on thetool/resultevent and handed back to yourpresentResult(so a card that needs more thanargs, likewrite/edit's applied-hunk diff, survives a session replay). Keep UI-only data here, never in the model-facingcontent. - Use
exec.agentfor async notifications.agent.inject(content, {source: {kind: 'plugin', plugin: '<name>'}})appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch).
Long-running work
Follow tool-bash's background pattern: a run_in_background flag returns a task id immediately; companion tools poll incrementally and kill; completion notices arrive via agent.inject(). Bound buffers and spill full output to disk so nothing is silently lost.
TODO: each tool reimplements this background pattern by hand today. At some point we need a generic long-running-tool layer that handles task ids, incremental polling, kill, and completion notices uniformly.
Execution policy and observation
Prefer not to build deployment policy into the tool. Use tools/pre-execute for extensible allow/deny/ask policy (the permission-gate example), ctx.tools.guard() for a final monotonic deny that later listeners cannot undo, tools/execute to wrap core dispatch with a deadline/retry/metrics scope, tools/post-execute to transform or attach model-facing context, and tools/result to observe the immutable normalized outcome without changing it. A sandboxing implementation can also sit behind the tool's executor capability seam; the exact contracts are in the dsh-tools README.
Code Mode reaches your tool for free
Under the registry's non-native mode (Code Mode), each visible registered capability is callable from a run_code program as await tools.<name>(args) — nothing to add. The registry keeps run_code itself as reserved, unfilterable presentation infrastructure while restrictions still control which end capabilities appear in the scoped SDK and bindings. The generated SDK declares parameters from the same JSON Schema defineTool emits (constructs outside that subset degrade to unknown); each program call receives its own immutable execution whose parent is the enclosing run_code token, then re-enters the complete pre/guard/around/post/result pipeline. A failed call rejects the program-side promise with your error text. Design description and parameter descriptions as JSDoc a model reads while writing code, and remember that non-text result blocks reach programs as placeholders (text is the bridge's lingua franca).
How your tool renders in an editor (ACP presentation)
Your tool's execute returns model-facing content; its editor card is a separate, optional concern you declare with two pure display methods on the defineTool options. Design this alongside execute, not after — an editor (Zed, over the ACP bridge) shows the card, and a tool with no presentation falls back to a bland generic card (title = tool name, raw args as input).
Both methods return a card-tagged render intent — pick the card kind that matches what your tool does:
presentCall(args)→ aToolCallView(the PENDING card):{ card: 'generic', title, kind?, rawInput?, content?, locations? }— the default. Setkindfor an icon (read/search/…); setlocations: [{ path, line? }]for any file your tool touches so a capable editor follows along / jumps to it.{ card: 'terminal', title, description?, cwd? }— your call IS a shell command.titleis the command,descriptionrenders above the terminal card. (tool-bash.){ card: 'diff', title, diffs, locations? }— your call creates or modifies a file.diffs: [{ path, oldText, newText }](oldText: nullfor a new file) renders as an inline diff card. (tool-fswrite/edit.)
presentResult(args, { content, isError, meta? })→ aToolResultView(the COMPLETED card):{ card: 'generic', title?, content? },{ card: 'terminal', title?, output?, exitCode?, signal? }(the run's captured output + exit — the bridge shows an exit pill and derives a fenced```consolefallback for editors without the terminal capability), or{ card: 'diff', title?, diffs }(a completed file mutation — the applied hunks computed from the before/after content when there is a before-image, else a whole-file diff for a create;write/editattach the hunks via themetachannel and read them back here). A mutation tool returns thediffresult even when it duplicates the call-time card, because an ACPtool_call_update.contentREPLACES the call's content — a non-diff result would clobber the pending diff.result.metais your tool's own optional presentation payload, attached fromexecute(see below) and persisted so a replay reproduces the card.
Hard rules (they bite if broken):
- Purity. These run on live streaming AND on session-log REPLAY, so they must be pure functions of
args(+ the result) — NO I/O, NO reading session state, NO clock/random. A diff is derived from the args (writeusesoldText: nullbecause a call-time presenter has no prior file content); the BRIDGE, not the tool, fills the session cwd and relativizes a display-path title. If you find yourself wanting the file's old content or the working directory insidepresentCall, stop — that belongs on the bridge or a future result-event shape, not the presenter. - UI-only formatting stays out of the model result. A fenced
```consoleblock, a diff, a relativized path — none of these may appear in whatexecutereturns to the model; they live only in the presentation. (Aterminalresult view carries RAWoutput; the bridge adds the fences.) defineToolsoft-validates the display path. A malformed/older logged arg shape makes the wrapper returnundefined(a generic fallback) rather than throw — display must never crash a replay.
The neutral vocabulary lives in dsh-tools (never import an ACP type into a tool); the ACP bridge maps each card to the wire. The design and the why are in the render-intent-union RFC; dsh-tool-fs (generic/diff) and dsh-tool-bash (terminal) are the reference implementations.
Tests every tool needs
Arg-validation rejections, result shaping for every outcome, the HMR disposal test, and — for tools with side effects — an integration spec that drives the tool through the agent loop with a scripted MockAdapter (packages/core/agent-loop/tests/mock-adapter.ts), asserting the tool/call / tool/result session events. If your tool has an editor card, also add: a unit test on presentCall/presentResult asserting the exact view shape, AND — because a unit test proves the shape but not that an editor renders it — a snapshot scenario under examples/acp-agent/tests/snapshots/ that drives the real tool through the ACP bridge and pins the rendered tool_call transcript (the card kind is only verified end-to-end there; see the ACP snapshot-tests RFC). A tool whose card is a terminal needs a scenario whose input.json sets terminalOutput: true to exercise the capable-client _meta path.