给 DeepSeek Harness 加一个工具:官方工具编写参考逐步拆

2026-08-17

先把限定条件说在前面:DeepSeek Harness 仓库根 package.json 里的版本是 0.1.0-rc.5,README 开头有一节 “Developer preview”,中文版写的是「目前处于开发者预览阶段,正在快速迭代」,并且用加粗写明未来将出现破坏兼容性的变更。下面提到的所有函数名、字段名、报错文案都可能在下一个 rc 里变掉,请以仓库当时的内容为准。我们没有安装也没有运行过这个项目,本文全部来自仓库里的文档与源码。

从一个具体问题开始:我想加个工具,最少要写多少

docs/cookbook/adding-a-tool.md(中文版 adding-a-tool.zh.md)开篇给的最小形态,那段 ts 代码块连 import 带 apply 一共 26 行:导出 nameinject = ['tools'],在 apply(ctx) 里调一次 ctx.tools.register(defineTool({...})),里面填 namedescriptionparametersoutputexecute。文档紧接着写了一句容易被跳过的话:注册是基于副作用的,dispose 掉插件 fiber 就等于注销这个工具;schema 会自动流入系统提示词的组装过程。

也就是说,你不需要在别处「登记」一次。docs/user/develop/basic/tool.md(中文版同名 .zh.md)那篇入门教程的做法就是把一个 greet 工具塞进 scratch-plugin,然后用

pnpm dsh web --patch ./scratch-plugin/cordis.yml

把插件挂上去。这条命令是从该教程原样抄下来的。

真正会绊人的不是这二十行,是注册表在收下这个定义之前做的那几次检查。

第一道关:output 不是可选的

packages/core/tools/src/index.tsregister(definition) 的函数体第一件事就是取 definition.output,如果它是 undefined、不是对象、output.render 不是函数,或者声明了 presentationMeta 却不是函数,直接抛 TypeError,文案是 tool "<name>" must declare output { schema, render, presentationMeta? }。然后才走 assertSupportedJsonSchema(output.schema)

这一条对着 cookbook 那句「声明并返回一个规范 JSON 值」看才有味道:output.schema 用的是 ValueSchemaSpec,根节点允许是对象、数组、标量或者 null;execute 只返回这个推导出来的值,注册表把它快照成无损 JSON、校验、冻结,再交给 output.render(args, value)。文档明确要求工具主体不要返回内容块,也不要让调用方从自然语言里去抠 id 和字段。

换句话说,这个框架把「给模型看的话」和「给程序用的值」硬拆成了两层,而且拆点就卡在注册这一步——你想偷懒只写 render 不写 schema,register() 直接不收。

第二道关:timeoutMs 被校验了两遍

这是我翻源码时觉得值得记一笔的地方。packages/core/tools/src/schema.ts 里的 defineTool 有这么一段:

if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
  throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
}

packages/core/tools/src/index.tsregister() 里还有一段判断条件完全一样的检查,抛的是 TypeError,文案是 tool "<name>" timeoutMs must be a positive finite number。两处的判断逻辑相同,抛出的错误类型和文案不同:一处是 Error、前缀 defineTool(name),另一处是 TypeError、前缀 tool "name"。我只陈述这个差异,不去推断哪一处是主、哪一处是补。

对你的实际影响是:如果你在写 catch 分支或者测试断言,别只按 TypeError 判,也别按其中一句文案去匹配——你走 defineTool 拿到的和绕过它直接注册原始定义拿到的,不是同一个类型。

顺带一提,timeoutMsDefineToolOptions 里的注释写的是「可选的正数协作式超时预算,单位毫秒」,没有默认值:源码里是 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),不填就不带这个字段。

第三道关:run_code 这个名字你抢不到

register() 的最后一道检查是名字:如果 name === RUN_CODE_NAME,抛出的错误说这个名字被 Code Mode 的展示传输保留,不能注册也不能被同名覆盖。源码里那段注释自述了理由——「无条件保留:任何 agent 都可能给自己选一个 code 模式,所以在部署默认配置下本来空闲的名字,一旦某个 preset 挂上来就会变成冲突」。这是文档自述的设计理由,不是我猜的。

packages/core/tools/README.md 里还写明,tools.restrict() 也不许点名这个保留传输,mode 配置有 native(默认)、codeboth 三档。

校验发生在哪一层:execute 会抛,展示器不会

defineTool 内部只编译一次参数 schema,然后拿同一个 validate 函数分四路用,行为却是三种:

回调参数校验不过时的行为
executeToolArgsError(violations),不进你的函数体
presentCall / presentResult返回 undefined,退回通用卡片
isConcurrencySafe返回 false

presentCall 那两行上面有一段注释写清了原因:展示是只读的,而且会在回放任意历史日志参数时运行(那些参数可能来自更老的 schema),所以它绝不能抛——软校验、不匹配就回退到通用 UI 展示,而不是像执行路径那样抛硬错误。cookbook 里对应的那条硬性规则写的是「defineTool 对展示路径做软校验」,两边是对得上的。

同一节还有一条更狠的硬性规则:presentCall / presentResult 必须是 args(加 result)的纯函数——不做 I/O、不读会话状态、不用时钟和随机数。文档给的判断标准很直接:如果你发现自己想在 presentCall 里去读文件旧内容或者工作目录,就停下,那属于持久结果元数据或者适配器,不属于展示器。write 工具的 diff 之所以用 oldText: null,就是因为调用时展示器手上根本没有文件的先前内容。

bash 当尺子量一遍

cookbook 第一段就点名 packages/shell/tool-bash 是「生产级的三包示例」。这个包的 src/index.ts 大约 394 行,ctx.tools.register(defineTool({ name: 'bash', ... })) 的参数表值得抄下来对照——它演示了怎么让 schema 随配置变形:

  • 恒定存在的:command(必填)、description(必填,给模型的说明里要求写成主动语态的 5-10 个词,并注明「shown in the UI」)、timeoutMsworkdir
  • run_in_background:只有 backgroundEnabled 为真才展开进 parameters
  • sandbox_permissions / justification:只有 escalationModes 非空才展开,enum 直接由运行时数组铺开

这就是为什么 docs/tool-catalog.md 的生成脚本要真的把每个插件启动起来读 ctx.tools.schemas(),而不是静态扫源码——那份文档头部自述的原因就是「工具 schema 不是静态可知的(运行时展开的 enum、拼接的描述、配置驱动的名字、原始 JSON Schema 的 MCP 工具)」。

它的 output.schema 是一个 oneOf,两个分支:后台分支返回 job 句柄,前台分支是一个 additionalProperties: false 的对象,字段包括 kind: 'foreground'const 约束)、exitCodesignaltimedOutabortedtimeoutMsstdout / stderr 两个各带 texttruncated、可选 spillPath 的子对象,再加一个 sandbox 子对象。包 README 里写明:非零退出仍然是由模型解释的结果,不会变成 isError;只有 spawn 错误、中止这类基础设施故障才是 isError

这条口径我建议你写自己的工具时照抄。它对应 cookbook 里那句「成功的领域结果即使表示不理想的状态,也应写入规范值」——命令跑挂了是结果,不是故障。

后台任务:id 发出去之后,取消信号就换人了

如果你的工具要跑长活,cookbook 的「长时间运行的工作」一节写得很具体:用 producer 配置控制 run_in_background,然后 ctx.jobs.start({ kind, label, owner: exec.agent, run })。有一句是最容易踩的:

ctx.jobs.start() 发布 id 后,应使用任务自有的取消信号,而不是 exec.signal

理由文档写了——之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作,那段生命周期归 job_kill、owner dispose 和服务 teardown 所有。前台工作则仍然与 exec.signal 耦合。

另外,预先就被中止的调用算失败,文档给的解释是此时没有任务,其 id 无法满足成功输出 schema。注册表这边对应的行为在 packages/core/tools/README.md 里:body 调用之前的取消是 ABORTED_BEFORE_DISPATCH,调用之后的取消只能把一个成功结果替换为 ABORTED,而拒绝、包装器失败、工具失败、后置策略失败或 TOOL_TIMEOUT 都比它更具体。

顺便说清楚一件事:这些工具是在本机起子进程、跑 shell 的。仓库里有沙箱相关的包和 sandbox_permissions 参数,但那是执行器挂载了沙箱才会出现的参数,能不能挂、挂成什么样是部署方的事——不要理解成「有沙箱所以随便跑都安全」。

策略别写进工具里

cookbook「执行策略与观测」一节开门见山:尽量不要把部署策略内建到工具中。可用的挂点在 packages/core/tools/README.md 的扩展点一节列全了——tools/pre-execute 是可重排的允许/拒绝/询问门禁,ctx.tools.guard() 加的是它之后的单调拒绝(后续监听器无法把拒绝翻回许可),tools/execute 用来包超时/重试/指标,tools/post-execute 可以替换展示内容或规范值、可以直接 block,tools/result 只观测不改。

有一句提醒值得单独拎出来:替换展示内容不是保密边界——程序化消费方照样能拿到 value;真要挡住,得 block 或者替换掉 value 本身。

三包分层,和 Windows 侧的那一份

「三包示例」指的是能力分层:packages/shell/shell 的 README 自述承担 bash 能力的 Service Definition 角色,packages/shell/bash-local 自述是这个 seam 的本地 Service Providerpackages/shell/tool-bash 在那张角色表里的写法是「架在 ctx.shell 之上的模型侧工具 schema」。入门教程「下一步」那节把这套叫做「将可替换能力拆分为 Service Definition、Service Provider 和 Consumer 三类包」。

顺带说一句,packages/shell/shell/README.md 那张表其实有四行不是三行:除 dsh-bash-local 外还列了 dsh-bash-sandbox,同样标成 Service Provider,README 自述它复用前者的机制、把每次 spawn 经 ctx.sandbox 约束、并把拒绝作为结果事实上报。也就是说消费者那一侧感知到的是「执行器有没有 sandboxMode 能力」——README 原话是 Consumer 检测这个能力后自行加上升级字段,不去 import 具体的 provider。你写自己的工具时想留同样的替换余地,抄的就是这个分法。

Windows 这边不要照抄 bash。docs/tool-catalog.md 的包映射表里写明 @deepseek-ai/dsh-tool-pwsh 是给 Windows 组合准备的 PowerShell 方言消费者,由 @deepseek-ai/dsh-pwsh-local 这类执行器支撑 ctx.shell,并写明它与 bash 工具逐调用对应但不含沙箱控制,每次调用起一个新进程(没有持久 PTY 会话),用原生 C:\... 路径和 $env:NAME 变量。你写跨平台工具时,命令拼装这一层得自己分开。

写完之后:别让它在目录里消失

docs/tool-catalog.md 是生成的,头部写明用 pnpm run gen-tool-catalog 重新生成、由 pnpm run verify-tool-catalog 校验新鲜度。它还有一个完整性守卫:生成脚本会 globSync('packages/*/tool-*'),只要有哪个包不在启动清单里就报错——文档原话是「新工具无法被悄悄漏掉文档」。

我按这个 glob 在仓库快照里实数了一遍:packages/*/tool-* 命中 21 个目录。而目录页里以 ### 开头的工具条目有 52 条;二级标题一共 25 个,除开头那节 Tool Package Map,其余 24 个都是以 @deepseek-ai/ 开头的包名小节——两个数字对不上是因为目录页还收了不属于 tool-* 命名的贡献方(比如注册表自己贡献的 run_codedsh-plan-modeexit_plan_modedsh-schedule 的三个 schedule_*),而完整性守卫只管 tool-* 那 21 个。这两组数字是我从这份快照里数出来的,不是文档自称的。

最后一句是 cookbook 自己的收尾:已交付且面向模型或 UI 的变更,必须提供 docs/testing.md 与所属包测试文档里规定的组装覆盖。packages/core/tools/tests 下我数出 12 个 .spec.ts 文件,packages/shell/tool-bash/tests 下 2 个,可以拿来当写测试时的参照。


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。