Adopts #211 (Code Mode tools: run_code + the code/both-mode snapshot scenarios). Tool-catalog expectations take the union (run_code + task_*); the two new pinsHeader fixtures (code-mode-turn, both-mode-turn) were recorded on master without the task runtime, so they are re-pinned KEYLESSLY by replaying their recorded chunks against the merged tree (same procedure as text-turn) — the fixture diff is exactly the header delta: task tool schemas, the tool:tasks prompt section, and the bash background wording.
10 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 { callId, name, arguments, agent?, signal? }
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. - Throwing means isError. The registry catches anything
execute()throws and returns{isError: true}to the model. Use that for infrastructure failures (bad input, spawn errors, aborts) — but REPORT domain failures in the result text instead (e.g. tool-bash returns[exit code: 9]withisError: false: 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
Hand long-running work to the shared task runtime instead of inventing a task protocol: gate a run_in_background parameter behind your plugin's own defaulted enableRunInBackground-style config, then call ctx.tasks.start({ kind, label, owner: exec.agent, run: () => ({ cancel, done, readOutput? }) }) (@deepseek-ai/dsh-tasks) — the runtime preflights everything that can fail (the control-surface fence, validation, owner-cleanup attach) BEFORE invoking your run() starter, so work that started without a collectable id is structurally impossible (no try/catch rollback in your tool). The runtime issues the <kind>-N id, fences access to the owning session, cancels-and-awaits your task when the owner disposes, and the generic task_output/task_list/task_kill tools plus the completion notice come from @deepseek-ai/dsh-tool-tasks — your tool returns started background task <id> and is done. Your producer keeps its execution concerns: done must settle at quiescence (resources released), and a stream-kind readOutput owns its own truncation/spill formatting (bound buffers, spill full output to disk so nothing is silently lost — see tool-bash's renderProcessRead). Do NOT wire exec.signal to the background work after the id is returned; check exec.signal?.aborted once before calling start, then leave cancellation to task_kill and owner cleanup.
Permissions / sandboxing
Prefer not to build policy into the tool. The seam is the tools/pre-execute gate (deny/ask — see the permission-gate example in extension-cookbook.md) and the tools/post-execute inspect/transform seam, or a sandboxing implementation behind the tool's executor seam.
Code Mode reaches your tool for free
Under the registry's non-native mode (Code Mode), a registered tool is ALSO callable from a run_code program as await tools.<name>(args) — nothing to add. The generated SDK declares your parameters from the same JSON Schema defineTool emits (constructs outside that subset degrade to unknown), each program call re-enters execute() through both waterfalls, and a failed call rejects the program-side promise with your error text. Two consequences worth designing for: your description and parameter descriptions become JSDoc a model reads while WRITING CODE, and non-text result blocks reach programs as placeholders (text is the lingua franca of the bridge).
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.