deepseek-harness 学习手册
动手实验室Lab 03 · 写一个最小的树外插件
45 min

写一个最小的树外插件

一个 prompt section 和一个工具,装在 profile 里,仓库零改动。

最后核对于 v0.1.1-rc.2 · commit b150a551b8 · 2026-08-22

这一课让你亲手写一个树外插件——住在你自己的 profile 里,跟仓库没有任何关系,但在运行时跟 227 个官方包完全平级。

两个产出:一段每次请求都会出现在 system prompt 里的文字,和一个模型能调的工具。写完你就理解了 M2 那句「你通过在其他插件旁边挂载一个插件来扩展 dsh」的字面含义。

1 建一个包

就是一个普通的 ESM npm 包。最小骨架:

~/my-dsh-plugin/package.jsonjson
{
  "name": "my-dsh-plugin",
  "version": "0.1.0",
  "type": "module",
  "main": "index.js",
  "peerDependencies": {
    "@deepseek-ai/cordis": "*"
  }
}
两条会咬人的约束

必须是 ESM。仓库到处写着 "type": "module"dsh 的源码启动路径走的是 tsx 的 ESM-only hook,它能到达的模块不能是 CJS-only 的导出。

不要 default export。用具名导出 name / inject / applydocs/postmortem/0001 记录的就是「default export 导致 inject 被丢掉」这个事故——所以现在几乎每个官方包的 README 里都有一节「Export shape」写明这件事。

2 先写最简单的:一段 prompt section

~/my-dsh-plugin/index.jsjs
/** Cordis 插件名,loader 诊断用。 */
export const name = 'my-dsh-plugin'

/** 我们要用的服务。声明了它,Cordis 会等它就绪再启动本插件。 */
export const inject = ['systemPrompt']

export function apply(ctx) {
  ctx.systemPrompt.section({
    name: 'my:house-rules',
    order: 500,
    text: '本工作区的约定:提交信息用中文,且必须引用一个 issue 编号。',
  })
}

三件事值得注意:

  • inject 就是依赖声明。加载顺序是从服务依赖推导出来的,不是手写的启动序列。
  • order 决定它在 prompt 里的位置——这直接关系到 KV cache(见 M4.4):越靠前的段落越稳定越好,因为改动它会让后面所有 token 的缓存作废。
  • 这里没有一句注册清理代码。section 是通过 effect 注册的,插件卸载时自动撤销。

3 装上它,验证它真的进了 prompt

terminal
$ dsh plugin --profile web add ~/my-dsh-plugin

相对路径规格(.../plugin,以及它们的 file: / link: 形式)锚定在你的调用目录,不是 profile 目录——所以在插件 checkout 里跑 dsh plugin --profile web add . 装的是那个 checkout。

然后在 profile 的补丁层里把它挂进树:

~/.dsh/profiles/web/cordis.patch.ymlyaml
- insert:
    - id: my-plugin
      name: my-dsh-plugin
terminal
$ dsh --profile web --dump-config | grep -A2 my-plugin

看见它了,说明它已经和官方那 200 多个包排在同一棵树上了。

4 再写一个工具

工具要用 defineTool,来自 @deepseek-ai/dsh-tools

~/my-dsh-plugin/index.js(加上这段)js
import { defineTool } from '@deepseek-ai/dsh-tools'

export const inject = ['systemPrompt', 'tools']

export function apply(ctx) {
  // …上面那段 section 不变…

  ctx.tools.register(defineTool({
    name: 'house_rule_check',
    description: '检查一条提交信息是否符合本工作区约定。',   // ← 模型看见的就是这句
    parameters: {
      message: { type: 'string', required: true, description: '待检查的提交信息' },
    },
    output: {
      schema: { type: 'object', properties: {
        ok: { type: 'boolean', required: true },
        reason: { type: 'string' },
      } },
      render: (_args, value) => [{ type: 'text', text: value.ok ? '符合约定' : value.reason }],
    },
    execute(args, _exec) {
      // args 已经按 schema 校验过了,这里它的类型是 { message: string }
      const ok = /#\d+/.test(args.message)
      return ok ? { ok } : { ok, reason: '缺少 issue 编号,例如 #123' }
    },
  }))
}

5 读懂这段代码背后的四条契约

cookbook 里的 execute() 契约有一长串,挑四条最容易踩的:

  1. 参数已经替你校验过了。defineToolexecute 跑之前,就按统一的 ParameterSchemaSpec 校验了模型生成的 arguments——类型、必填键、字面量约束、恰一联合、嵌套值。但 DSL 表达不了的约束(非空字符串、正数、跨字段规则)还得你自己查。
  2. 只返回一个规范的 JSON 值,别返回内容块。注册表会把它快照成无损 JSON、校验、冻结,再交给 output.render(args, value)不要让调用方从散文里解析 id 和字段。
  3. 抛错 = isError基础设施失败就抛。但一个成功的领域结果即使不理想(比如进程非零退出),也应该表达在规范值里,而不是抛出来。
  4. 尊重 exec.signal它一触发,就取消在飞的工作。
白捡的两件事

Code Mode 免费接上。每一个可见的已注册工具,在 Code Mode 里都能直接 await tools.house_rule_check({ message }),不用做任何额外集成;参数与返回类型从同一份 schema 推出来。

UI 卡片有默认值。没写 presentCall / presentResult 的工具会落到一个通用卡片(标题=工具名,原始参数当输入)。但 AGENTS.md 提醒:「一个工具的 UI 渲染意图是它设计的一部分,要提前决定」,别等到最后再补。

对比即教学

同样这件事,在别家要几步?

dsh 同进程 一个 npm 包,跟核心插件同级。能注册 prompt section、工具、监听器、服务,还能被别的插件 inject。
MCP(各家通用) 跨进程 一个独立进程 + 一段协议。能加工具,但改不了 prompt 装配、拦截不了 turn、也无法替换内置实现。
待核对
Claude Code hooks shell 在生命周期点上跑外部命令。dsh 树内就有这个协议的桥接包,把它翻译到自己的拦截点上。
待核对
这一格的取舍很清楚:MCP 用隔离换了通用性(一个 server 能被所有家用),同进程插件用绑定换了能力(只能给 dsh 用,但能碰到的东西多得多)。dsh 两条路都留着——CLI 甚至预装了 @deepseek-ai/dsh-mcp-client 给补丁层用,只是默认不启用任何 MCP server,因为每个 server 命令都是沙箱之外的可信可执行代码
下一课

Lab 4 · 复刻一个 web search provider——从「加一个工具」升级到「往 seam 里加一个实现」。60 分钟。

目录

本页

Lab 03 · 写一个最小的树外插件