实战:为 dsh 写一个自己的工具插件

专栏:DeepSeek Harness · 第 14 / 14 篇
DeepSeek Harness实战插件开发

专栏走到收官。前十二篇我们把 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。
  • defineToolparameters —— 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 篇的体验环节在这里闭环。这条命令背后发生了什么:

图表(write-your-own-plugin.md)

--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 篇画的路线图:

图表(write-your-own-plugin.md)

当初定下的目标——「你能自信地改它,而不只是读它」——现在有了具体的检验标准:给 dsh 加一个能力,你知道该写成 Definition/Provider/Consumer 的哪一角、注册到哪个 ctx 键、挂进哪一层 patch、用哪个事件做策略、怎么让它可测试。这份「改得动」的底气,就是读源码的全部回报。

dsh 还在快速迭代(开发者预览阶段),接口会变,但插件化、事件溯源、fail-closed 这三条设计主轴大概率会一直在。专栏正文到此,后续有值得记录的大版本变化我会以番外形式更新。欢迎 RSS 订阅同行。

← 返回文章列表