Tool authoring reference

Reference for the contracts a model-facing tool must satisfy. For an ordered first tool, follow Build a tool. packages/shell/tool-bash is the production-grade three-package example.

The minimal shape

import { readFile } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/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
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    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 readFile(args.path, { encoding: 'utf8', signal: exec.signal })
    },
  }))
}

Registration is effect-based: disposing the plugin fiber unregisters the tool. Schemas flow into the system-prompt assembly automatically.

Rules of the execute() contract

  • Args are validated for you. defineTool validates model-generated arguments against the unified ParameterSchemaSpec before execute runs (types, required keys, literal constraints, exact-one unions, and nested values — runtime arg validation), so inside execute the args match InferArgs. Explicit object nodes declare additionalProperties: true | false; the implicit parameter root stays open. You still hand-check constraints the DSL does not express, such as non-empty strings, positive numbers, or cross-field rules. Raw JSON-Schema tools registered directly own their input validation.
  • 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 arguments as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque exec.token; callId, name, arguments, agent, token, the required caller-owned signal, and an optional enclosing-transport parent token stay immutable through dispatch. parent is identity-only and exposes no live outer execution. Treat args as readonly input. Only an around-dispatch wrapper receives a mutable view, and it may replace and restore the required exec.signal to impose a deadline but cannot remove it.
  • Declare and return one canonical JSON value. output.schema uses ValueSchemaSpec and may have an object, array, scalar, or null root. execute returns only the inferred value; the registry snapshots it as lossless JSON, validates it, freezes it, and passes it to output.render(args, value). Do not return content blocks from the body or make callers parse prose for ids and fields.
  • Throwing or returning an invalid value means isError. The registry catches throws and contains schema, renderer, metadata-projector, and lossless-JSON failures before observers run. Throw for infrastructure failures. Represent a successful domain outcome in the canonical value even when its Native renderer explains a non-ideal state, such as a non-zero process exit.
  • Honor exec.signal. Cancel in-flight work when it fires.
  • Project durable card data with presentationMeta (optional). output.presentationMeta(args, value) derives replayable JSON from the same canonical value. The core persists it on tool/result and hands it to presentResult, so a card that needs result-time facts—such as write/edit applied hunks—survives replay without persisting the canonical value. The projector is skipped for nested Code dispatches because they have no cards.
  • Use exec.agent for 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

Gate run_in_background with producer config, then register through ctx.jobs.start({ kind, label, owner: exec.agent, run }). The registry rejects a pre-aborted invocation before the producer body; the runtime validates ownership and task-controller availability before run() starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. A successful background branch returns a typed canonical handle such as { kind: 'background', jobId }; its Native renderer may keep human prose such as started background job bash-1, but Code Mode must never parse that prose to recover the id.

The producer supplies synchronous cancel, non-rejecting done that settles after resource cleanup, and optional consuming readOutput with bounded-output formatting. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once ctx.jobs.start() publishes the id, use a task-owned cancellation signal rather than exec.signal: later outer-call cancellation stops waiting for the call but does not kill published work; job_kill, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to exec.signal. See the background job runtime Agent Note and dsh-tool-bash for a stream producer.

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 dispatch with a deadline, retry, or metrics collection, tools/post-execute to replace presentation content or the returned value, block the result, or attach model-facing context, and tools/result to observe the immutable normalized outcome. A content replacement leaves programmatic access to value intact; confidentiality policy blocks or replaces the value. A sandboxing implementation can also run inside the tool's executor implementation; the dsh-tools README defines each extension point's inputs, order, return values, and failure behavior.

Code Mode reaches your tool for free

In Code Mode, every visible registered tool is available as await tools.<name>(args) without extra integration. The generated ToolArgsMap and ToolOutputMap derive exact argument and canonical-return types from the same schemas, and calls re-enter the normal execution pipeline. A successful call resolves to the final canonical JSON value after policy, not to rendered Native content. A failed call rejects with the real ToolCallError; programs can inspect only its name, toolName, and human-readable message, not internal error codes or a failure union.

Design output.schema as a useful programmatic API: return handles and fields directly, allow scalar/array/null roots when they are the honest value, and keep human explanation in output.render. Intermediate values are execution-local, are not persisted or prompt-truncated, and have no byte cap, so the producer's truthful acquisition bounds and process memory still matter. Only the outer run_code logs/result cross the configurable output cap and model-facing spill pipeline.

How your tool renders in a UI

Your tool's output.render returns model-facing content; its UI card is a separate concern declared through pure presentation projections and optional presentCall / presentResult methods. Design these alongside the canonical value. A tool with no UI presentation falls back to a 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) → a ToolCallView (the PENDING card):
    • { card: 'generic', title, kind?, rawInput?, content?, locations? } — the default. Set kind for an icon (read/search/…); set locations: [{ 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. title is the command, description renders above the terminal card. (tool-bash.)
    • { card: 'diff', title, diffs, locations? } — your call creates or modifies a file. diffs: [{ path, oldText, newText }] (oldText: null for a new file) renders as an inline diff card. (tool-fs write/edit.)
  • presentResult(args, { content, isError, meta? }) returns the completed card:
    • generic supplies an optional title and content.
    • terminal supplies raw output and optional exit metadata; each UI renders its capable or fallback view.
    • diff supplies applied hunks, often derived by output.presentationMeta and carried in persisted result.meta so replay reproduces them. Mutation tools keep a diff result because the completed view replaces the pending card.
    • search supplies a discovery result reconstructed from persisted result.meta: grouped-by-file matches (shape: 'matches', grep) or a flat path list (shape: 'paths', glob), plus truncated/total so a UI never presents a capped result as complete. The view carries no result text (a UI without a search card falls back to the raw result content), and there is no search call view — a discovery call's pending state stays a generic card, since matches exist only after execute. (tool-fs-search grep/glob.)
    • web supplies a completed web retrieval, discriminated by kind: 'search' | 'fetch' (the structured search sources or the fetch summary), derived from result.meta; it carries no body copy, so a UI without the web capability falls back to the raw result content. (tool-web web_search/web_fetch.)

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 (write uses oldText: null because a call-time presenter has no prior file content); the UI adapter, not the tool, supplies session context. If you find yourself wanting the file's old content or the working directory inside presentCall, stop — that belongs in durable result metadata or the adapter, not the presenter.
  • UI-only formatting stays out of the model result. A fenced ```console block, a diff, a relativized path—none of these belongs in the canonical value or Native content merely to serve a UI. output.render owns model-facing prose; presentationMeta plus the card presenters own replayable UI state. A terminal result view carries raw output and the adapter adds any fallback framing.
  • defineTool soft-validates the display path. Malformed or older logged arguments make the wrapper return undefined (a generic fallback) rather than throw — display must never crash a replay.

The neutral vocabulary lives in dsh-tools; tools never import a UI or transport type. Host/client runtimes map each card into their own view. The design and the why are in the render-intent-union Agent Note; dsh-tool-fs (generic/diff) and dsh-tool-bash (terminal) are the reference implementations.

Verification

Follow the repository testing policy and the owning package's test documentation. A shipped model- or UI-visible change requires the assembled coverage specified there.

工具编写参考

面向模型的工具必须满足哪些约定,均以本文为准。如需按步骤构建第一个工具,请阅读构建工具packages/shell/tool-bash 是生产级的三包示例。

最小形态

import { readFile } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/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
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    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 readFile(args.path, { encoding: 'utf8', signal: exec.signal })
    },
  }))
}

注册基于副作用:dispose(资源释放)插件 fiber 即注销该工具。schema 会自动流入系统提示词的组装过程。

execute() 约定的规则

  • 参数已为你校验。 defineToolexecute 运行前,会根据统一的 ParameterSchemaSpec 校验模型生成的 arguments(类型、必填键、字面量约束、恰好匹配一个分支的联合以及嵌套值——见运行时参数校验),因此 execute 内的 args 会匹配 InferArgs。显式对象节点必须声明 additionalProperties: true | false;隐式参数根对象保持开放。你仍需手动检查 schema DSL 无法表达的约束,例如非空字符串、正数或跨字段规则。直接注册的原始 JSON Schema 工具自行负责输入校验。
  • 注册借用你的只读定义。 类型化的同进程贡献不是序列化边界;注册后不要修改其 schema 或替换回调。schemas() 只物化显式的模型可见投影。如需热替换工具,请 dispose 其所属副作用并注册替代品;回调闭包内的可变状态仍是普通的插件状态。
  • 执行身份受保护。 注册表在一次递归遍历中将 arguments 物化为分离的无损 JSON,在策略开始前冻结该值,并分配一个不透明的 exec.tokencallIdnameargumentsagenttoken、必填且由调用方持有的 signal,以及可选的外层传输 parent token 在整个分发过程中保持不可变。parent 仅用于身份标识,不暴露活跃的外层执行。请将 args 视为只读输入。只有 around-dispatch 包装器会收到可变视图;它可以替换并恢复必填的 exec.signal 以施加截止时间,但不能移除该信号。
  • 声明并返回一个规范 JSON 值。 output.schema 使用 ValueSchemaSpec,根可以是对象、数组、标量或 null。execute 只返回推导出的值;注册表将其快照为无损 JSON,完成校验和冻结后,再传给 output.render(args, value)。工具主体不要返回内容块,也不要迫使调用方从自然语言中解析 id 和字段。
  • 抛出异常或返回无效值意味着 isError 注册表会捕获异常,并在观察者运行前收敛 schema、渲染器、元数据投影器和无损 JSON 失败。基础设施故障请抛异常。成功的领域结果即使表示不理想的状态,也应写入规范值;其 Native 渲染器可以解释该状态,例如进程以非零状态退出。
  • 遵守 exec.signal 信号触发时取消进行中的工作。
  • 使用 presentationMeta 投影持久化的卡片数据(可选)。 output.presentationMeta(args, value) 从同一个规范值派生可回放的 JSON。核心将其持久化在 tool/result 上并传给 presentResult,因此需要结果期事实的卡片——例如 writeedit 的已应用 hunk——无需持久化规范值也能在回放中重现。嵌套 Code 分发没有卡片,因此会跳过该投影器。
  • 使用 exec.agent 发送异步通知。 agent.inject({ content, source: { kind: 'plugin', plugin: '<name>' } }) 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。请防范已 dispose 的 agent(try/catch)。

长时间运行的工作

通过 producer 配置控制 run_in_background,然后使用 ctx.jobs.start({ kind, label, owner: exec.agent, run }) 注册任务。注册表会在进入 producer 主体前将已预先中止的调用判为失败;运行时会在 run() 启动工作前校验 owner 和任务控制器是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。成功的后台分支会返回类型化的规范句柄,如 { kind: 'background', jobId };其 Native 渲染器可以保留 started background job bash-1 这类供人阅读的自然语言,但 Code Mode 绝不能通过解析该文本取得 id。

producer 提供同步的 cancel、在资源清理后 settle 且不 reject 的 done,以及可选的消费式 readOutput(负责有界输出的格式化)。预先中止的调用属于失败,因为此时没有任务,其 id 无法满足成功输出 schema。ctx.jobs.start() 发布 id 后,应使用任务自有的取消信号,而不是 exec.signal:之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作;该生命周期归 job_kill、owner dispose 和服务 teardown 所有。前台工作仍与 exec.signal 耦合。流式 producer 的示例和完整约定见后台任务运行时 Agent Notedsh-tool-bash

执行策略与观测

尽量不要把部署策略内建到工具中。使用 tools/pre-execute 实现可扩展的允许/拒绝/询问策略(见权限门禁示例);使用 ctx.tools.guard() 设置最终的单调拒绝,后续监听器无法撤销;使用 tools/execute 为分发添加截止时间、重试或指标收集;使用 tools/post-execute 替换展示内容或返回值、阻止结果,或附加模型可见上下文;使用 tools/result 观测不可变的归一化结果而不改变它。替换内容不会阻止程序化访问 value;保密策略会屏蔽或替换该值。沙箱实现也可以在工具的执行器实现中运行;dsh-tools README 定义每个扩展点的输入、顺序、返回值和失败行为。

Code Mode 自动触达你的工具

Code Mode 中,每个可见的已注册工具都可通过 await tools.<name>(args) 调用,无需额外集成。生成的 ToolArgsMapToolOutputMap 会根据同一组 schema 分别派生精确的参数类型与规范返回类型,调用则重新进入正常的执行流水线。成功调用会解析为策略处理后的最终规范 JSON 值,而不是渲染后的 Native 内容。失败调用会以真正的 ToolCallError reject;程序只能检查其 nametoolName 和可供人阅读的 message,无法取得内部错误代码或失败联合。

请把 output.schema 设计为实用的程序化 API:直接返回句柄与字段;当标量、数组或 null 确实就是结果时,允许采用相应的根类型;将面向人类的解释放入 output.render。中间值只存在于执行期间,不会被持久化或按提示词上限截断,也不设字节上限,因此生产方如实声明的采集边界和进程内存仍然重要。只有外层 run_code 日志/结果会受到可配置输出上限和面向模型的 spill 流水线约束。

工具在 UI 中的渲染方式

工具的 output.render 返回模型可见的内容;其 UI 卡片 是另一项独立关注点,通过纯展示投影以及可选的 presentCallpresentResult 方法声明。请将这些内容与规范值一并设计。没有 UI 展示方法的工具会回退到通用卡片(标题 = 工具名,原始 args 作为输入)。

两个方法都返回一个 card 标签的渲染意图——选择与你的工具行为匹配的卡片类型:

  • presentCall(args) → 一个 ToolCallView(PENDING 卡片):
    • { card: 'generic', title, kind?, rawInput?, content?, locations? }——默认。设置 kind 获取图标(readsearch/…);设置 locations: [{ path, line? }] 标注工具涉及的文件,使有能力的编辑器跟随/跳转。
    • { card: 'terminal', title, description?, cwd? }——你的调用本身就是 shell 命令。title 是命令,description 渲染在终端卡片上方。(tool-bash。)
    • { card: 'diff', title, diffs, locations? }——你的调用创建或修改文件。diffs: [{ path, oldText, newText }](新文件时 oldText: null)渲染为内联 diff 卡片。(tool-fs writeedit。)
  • presentResult(args, { content, isError, meta? }) 返回完成后的卡片:
    • generic 提供可选的标题和内容。
    • terminal 提供原始输出和可选的退出元数据;各 UI 根据自身能力渲染对应视图或回退视图。
    • diff 提供已应用的 hunk,通常由 output.presentationMeta 派生并通过持久化的 result.meta 携带,使回放能重现它们。变更类工具保留 diff 结果,因为完成后的视图会替换 pending 卡片。
    • search 提供从持久化 result.meta 重建的发现型结果:按文件分组的匹配(shape: 'matches',grep)或扁平路径列表(shape: 'paths',glob),外加 truncatedtotal 使 UI 永不把被截断的结果当作完整结果呈现。该视图不携带结果文本(无 search 卡片的 UI 回退到原始结果内容),也没有 search 调用视图——发现型调用的 pending 状态保持为 generic 卡片,因为匹配只在 execute 之后才存在。(tool-fs-search 的 grepglob。)
    • web 提供已完成的 web 检索,以 kind: 'search' | 'fetch' 区分(结构化的搜索来源或抓取摘要),由 result.meta 派生;它不携带正文副本,因此不具备 web 能力的 UI 回退到原始结果内容。(tool-web web_searchweb_fetch。)

硬性规则(违反会出问题):

  • 纯函数。 这些方法在实时流式输出和会话日志回放时都会运行,因此必须是 args(加 result)的纯函数——不做 I/O、不读会话状态、不用时钟/随机数。diff 从 args 派生(write 使用 oldText: null,因为调用时的展示器没有文件先前内容);会话上下文由 UI 适配器而非工具提供。如果你发现自己想在 presentCall 内获取文件旧内容或工作目录,请停下:那属于持久结果元数据或适配器,不属于展示器。
  • UI 格式不进入模型结果。 围栏 ```console 块、diff、相对化路径均不应仅为服务 UI 而进入规范值或 Native 内容。output.render 负责模型可见的自然语言;presentationMeta 和卡片展示器负责可回放的 UI 状态。terminal 结果视图携带原始输出,由适配器按需添加回退格式。
  • defineTool 对展示路径做软校验。 格式错误或旧版日志中的参数会使包装器返回 undefined(通用回退)而非抛异常——展示绝不能导致回放崩溃。

中性词汇定义在 dsh-tools 中;工具绝不导入 UI 或传输类型。host/client 运行时将每个 card 映射到各自的视图。设计与原因见渲染意图联合体 Agent Notedsh-tool-fs(generic/diff)和 dsh-tool-bash(terminal)是参考实现。

验证

遵循仓库测试策略和所属包的测试文档。已交付且面向模型或 UI 的变更必须提供其中规定的组装覆盖。