实战:为 dsh 写一个自己的工具插件
专栏:DeepSeek Harness · 第 14 / 14 篇专栏走到收官。前十二篇我们把 dsh 从启动读到了安全模型,这篇全部落地:亲手写一个工具插件,挂进 profile,看着模型用上它。核心代码来自官方 cookbook(docs/cookbook/adding-a-tool.zh.md),我们逐行讲透。
最小工具:一个文件就是一个插件
「读一个文件」的工具,完整实现:
// docs/cookbook/adding-a-tool.zh.md:9-36
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.', // 模型可见的一句话说明
parameters: {
path: { type: 'string', required: true, description: 'Absolute path' },
limit: { type: 'number' }, // 不写 required 即可选
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
// args 是从 schema 推导的类型:{ path: string; limit?: number }
// exec 携带不可变身份 + signal;务必遵守 signal 以支持取消
return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
},
}))
}
回顾前几篇,这 30 行里每一行都踩在机制上:
inject = ['tools']—— Cordis 依赖门控(第 4 篇):注册表未就绪则本插件保持 PENDING。defineTool的parameters——execute运行前自动校验模型参数(类型/必填/字面量/联合分支),args因此获得静态类型;校验失败以INVALID_ARGS结构化错误回流,不炸轮次(第 9 篇)。output.schema+render—— 返回值先快照校验再冻结,render 把规范值变成模型可见内容;register()的强校验(第 9 篇摘录)就是在这里把关。exec.signal—— 调用方取消的传播链(第 9 篇的信号熔合);工具体里不尊重 signal,取消就停不下来。ctx.tools.register返回的 disposer 自动挂到本插件 fiber —— 卸载/热重载即从模型可见工具列表消失(第 4 篇 effect 模型)。
schema 会自动流入 system-prompt 组装(第 8 篇)——你不需要手写任何「我有什么工具」的文本。
挂载:两行 YAML
官方教程的最小挂载方式——一个 scratch 目录,一个 patch 清单:
# scratch-plugin/cordis.yml —— --patch 加载的插件清单
- insert:
- id: my-tool
# 路径必须是绝对路径:patch 只贡献配置,
# 不会改变 Loader 解析模块路径时使用的 profile 目录
name: /absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts
pnpm dsh web --patch ./scratch-plugin/cordis.yml
打开 http://127.0.0.1:3080,对模型说「Use the read_file tool to read /etc/hostname」——第 2 篇的体验环节在这里闭环。这条命令背后发生了什么:
--patch 在层序里排最后(第 5 篇)——你的 id 可以覆盖任何发行版行。想长期挂在某个 profile?dsh plugin --profile my-profile add <包路径> 会把它装进 profile 的 pnpm workspace 并写进 bundles。
进阶一:策略不要写进工具
工具写多了最容易犯的错是把「什么时候允许用」写进 execute。dsh 的答案是五个扩展点各司其职(第 9 篇那句口诀)。官方 cookbook 的权限门禁示例:
// docs/cookbook/extension-cookbook.zh.md:17-33
import type { Context } from '@deepseek-ai/cordis'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
declare function isAllowed(exec: ToolExecution): Promise<boolean>
export const name = 'permission-gate'
export function apply(ctx: Context) {
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (!(await isAllowed(exec))) {
return { kind: 'deny', reason: 'Denied by policy.' } // 短路:不调 next()
}
return next() // 观察者必须委托
})
}
选点速查(源自 extension-cookbook 的决策表):
| 想做的事 | 机制 |
|---|---|
| 准入控制 / 转审批 | tools/pre-execute 返回 deny / ask + ctx.approval 应答(第 12 篇) |
| 终局禁止(不可翻案) | ctx.tools.guard()(单调,监听顺序无法放行) |
| 超时 / 重试 / 指标 | tools/execute wrapper(只能换 exec.signal) |
| 结果改写 / 附加上下文 | tools/post-execute(accept 换 content/value、block 纠偏) |
| 只读观测 | tools/result emit |
| 新模型提供方 | ctx.llm.registerAdapter(第 11 篇) |
| MCP 服务器 | 每服务器一个插件,发现工具后 ctx.tools.register() |
| UI 卡片 | client 插件在 keyed slot 按工具名注册卡片(第 13 篇) |
进阶二:长任务交给 jobs
工具超过几十秒就该考虑后台化:defineTool 配置里声明 run_in_background producer,执行体改用 ctx.jobs.start({kind, label, owner: exec.agent, run}),立刻返回 {kind:'background', jobId} 句柄;后台生命周期归 job_kill 工具与 owner 会话管理,完成以会话通知送达(第 13 篇)——不再占用 exec.signal 的同步世界。
进阶三:LLM 适配器与包
想接一个新模型?class MyAdapter extends LlmAdapter + 实现 stream() + ctx.llm.registerAdapter(['my-provider'], adapter),义务清单在 cookbook(usage 先于 finish、工具参数全程原始 JSON 串、错误只有两条合法路径)。想正式给仓库加一个包?四件套起步(package.json / tsconfig.json / src/index.ts / 带强制结构的 README.md),加进对应面的 aggregate,pnpm run constraints && typecheck && lint && build 验证——第 3 篇的构建体系在这里派上用场。
交付级验证清单
教程级验证是「Web 里让模型用一次」;交付级遵循仓库的测试纪律(以 tool-bash 为例):
packages/<group>/<pkg>/
├─ package.json # private + type:module + cordis 同时在 peer/devDeps
├─ tsconfig.json # extends 根 base + references 依赖包
├─ src/index.ts # name / inject / Config / apply
├─ README.md # 服务 API + Model Experience + Known Limitations(验证器把关)
└─ tests/
├─ tools.spec.ts # 行为专项
└─ integration.spec.ts # 组装覆盖
pnpm install && pnpm run doc-sync && pnpm run constraints \
&& pnpm run typecheck && pnpm run lint && pnpm run build && pnpm run hygiene
与其他模块的联系
这篇实战把全专栏的机制串成一条线:
- 工具注册依赖 Cordis 的 inject 门控(第 4 篇)与 defineTool 校验(第 9 篇);
- schema 自动进入 system-prompt 组装(第 8 篇),无需手写任何”我有什么工具”;
- 挂载走 —patch overlay(第 5 篇层序的最后一级),
dsh plugin add长期化; - 策略别写进工具——五个扩展点选点(第 9 篇),审批走 ctx.approval(第 12 篇);
- UI 卡片在 client 插件的 keyed slot 注册(第 13 篇)。
番外:本专栏未覆盖的模块
dsh 有 50 个包,专栏聚焦在主干与设计思想上,这些模块值得自己继续探索:
- extensions(运行时自修改):模型在会话中实时挂载/卸载插件与服务——agent 自我扩展能力的边界探索;
- goal:同会话目标的持久化与生命周期(agent 的”长期承诺”);
- webhook:外部事件验证后自动创建会话——从”人找 agent”到”事件找 agent”;
- mcp:dsh 对 Model Context Protocol 的支持(Agent 专栏第 11 篇讲过协议本身);
- schedule:会话内的定时后续操作;
- code-runtime:PTC 模式的 worker 线程运行时(模型写代码调工具,而非逐个 tool_call);
- session-query:会话语料的检索层(SQLite 全文 + 语义过滤)。
它们的共同点:都建立在你已经掌握的地基上——插件注册、事件流、能力 seam。读懂了主干,这些枝叶读起来会非常快。
收官:专栏路线的闭环
十四篇走完,回头看第 1 篇画的路线图:
当初定下的目标——「你能自信地改它,而不只是读它」——现在有了具体的检验标准:给 dsh 加一个能力,你知道该写成 Definition/Provider/Consumer 的哪一角、注册到哪个 ctx 键、挂进哪一层 patch、用哪个事件做策略、怎么让它可测试。这份「改得动」的底气,就是读源码的全部回报。
dsh 还在快速迭代(开发者预览阶段),接口会变,但插件化、事件溯源、fail-closed 这三条设计主轴大概率会一直在。专栏正文到此,后续有值得记录的大版本变化我会以番外形式更新。欢迎 RSS 订阅同行。