Codex's PR-C review found two (A) blockers: - tools/post-execute could corrupt the protected outcome. postExecute passed the mutable `result` to listeners and then read result.callId / spread result on the return paths, so a listener mutating the reference (flipping isError, rewriting callId, injecting an error) escaped the decision channel. Now the authoritative callId/isError/error are SNAPSHOT before the waterfall and the return value is rebuilt from the snapshot + the typed PostToolDecision — the decision is the only sanctioned way to change the outcome, and callId is always exec.callId. Added a regression test that mutates the result reference and asserts it has no effect; proven to fail red on the unfixed code. - Public docs/JSDoc still advertised the removed `tools/execute` waterfall after the split. Swept every current-state reference to tools/pre-execute + tools/post-execute: the ToolRegistry class JSDoc (and the regenerated catalog), loop.ts's ASCII flow (also added the prompt-submit/session-start steps it was missing), the package-map READMEs (packages, core, agent-core), core-data-structures core.md/tools.md, the bash + acp + invariants src/READMEs (the deferred permission gate is the tools/pre-execute deny/ask seam now), the cookbook, and the implemented RFCs whose factual seam catalog drifted. codec.ts's totality prose now lists `rejected`. Proposed-RFC references are left as-is (frozen proposals, validated when built).
3.7 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. - 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.
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.
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.