dshkit

写你的第一个 DeepSeek Harness 插件

dsh 插件就是带具名导出的 Cordis 插件。模块契约、用 inject 声明服务依赖、schemastery 配置、用 defineTool 注册工具、追加 session 事件,以及在 cordis.patch.yml 里挂载那一行。

更新于 2026-08-134 min
一句话结论

dsh 插件是一个带具名导出的模块:name、inject(需要的服务键)、Config(schemastery schema)、apply(ctx, config)。用 ctx.tools.register(defineTool({...})) 注册工具。挂载靠在 cordis.patch.yml 里 insert 一行带 id 和包名的记录,然后用 dsh --profile web --dump-config 确认。

到目前为止,关于 DeepSeek Harness 的文章几乎都是从外面描述它。这一页是从里面写的:真实的模块契约, 取自 harness 自带工具的源码。

全文的参照物是 @deepseek-ai/dsh-tool-todo——也就是 todo_write 工具。它小到可以通读,又几乎用满了 插件的全部接口面。

插件是什么

Cordis 插件是实现 Service 接口的对象,有两种形态:带可选 injectapply(ctx) 属性的函数, 或者继承 Service 的类(由 Cordis 管理其生命周期)。

但实际上,harness 自带的工具两种都不是——它们是带具名导出的模块

import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
 
export const name = 'tool-todo'
export const inject = ['tools']
 
export interface Config {
  allowParallelInProgress: boolean
}
 
export const Config: z<Config> = z.object({
  allowParallelInProgress: z.boolean().required(),
})
 
export function apply(ctx: Context, config: Config): void {
  // 在这里注册东西
}

四个具名导出:nameinjectConfig(两次——类型和 schema)、apply

inject:依赖 seam,不依赖实现

export const inject = ['tools']

这声明了你的插件需要哪些服务键。框架会等到这些服务存在才激活你——所以加载顺序是通过服务依赖表达的, 而不是靠手工排启动序列。你永远不用写加载顺序。

背后的架构规则是:扩展插件依赖服务定义,永不依赖具体的提供者ctx.tools 是一个 seam——定义、 它的提供者、它的消费者三者合起来。你注入这个键;谁提供它不关你的事。

可用的 seam 包括 ctx.toolsctx.llmctx.shellctx.sandboxctx.approvalctx.fsctx.webctx.subagentsctx.jobsctx.terminalsctx.storagectx.credentialsctx.settingsctx.sessionQueryctx.systemPromptctx.codeRuntimectx.lspctx.workflowEngine 等等。

可选依赖

对于「可能被组装、也可能没有」的 seam,改成在 apply 内部注入:

export function apply(ctx: Context, config: Config): void {
  ctx.inject(['sessionProjections'], (projectionCtx) => {
    projectionCtx.sessionProjections.register({ /* … */ })
  })
}

回调只在该服务存在时才执行。在 tool-todo 里,这就是「有 projection 注册表时才激活投影单元、 没有这个 seam 的 headless 组装不受影响」的做法。「锦上添花」的能力都该用这个模式——写在模块层的硬 inject 会让你的插件在缺少该服务时完全不激活

Config

两个同名导出:一个 TypeScript 接口,一个对应的 schemastery schema。

export interface Config {
  allowParallelInProgress: boolean
}
 
export const Config: z<Config> = z.object({
  allowParallelInProgress: z.boolean().required(),
})

schema 用来校验部署方在 YAML 里写的 config: 块。注意 z.boolean().required()——tool-todo 把它做成部署方必须做的选择而不是给个默认值,因为正确答案取决于该部署是否并发跑活。 当不存在安全默认值时,强制对方做决定是正当的设计动作。

注册一个工具

export function apply(ctx: Context, config: Config): void {
  ctx.tools.register(defineTool({
    name: 'todo_write',
    description: describe(config.allowParallelInProgress),
    parameters: {
      todos: {
        type: 'array',
        required: true,
        description: '完整任务列表,替换掉之前的列表。',
        items: {
          type: 'object',
          additionalProperties: false,
          properties: {
            content: { type: 'string', required: true, description: '这个任务是什么。' },
            status: {
              type: 'string',
              required: true,
              enum: ['pending', 'in_progress', 'completed'],
              description: 'pending | in_progress | completed',
            },
          },
        },
      },
    },
    output: {
      schema: { /* 同一套形状语言,描述返回值 */ },
      render: (_args, value) => [{
        type: 'text',
        text: `Updated todo list: ${value.counts.pending} pending…`,
      }],
    },
    execute(args, exec) {
      // …
    },
    presentCall: args => ({
      card: 'generic',
      title: 'Update todo list',
      kind: 'other',
      rawInput: args.todos,
    }),
  }))
}

六个部分值得讲清楚。

parameters 不是原生 JSON Schema。 必填性是逐属性内联写的(required: true),不是同级的 required: [] 数组。如果你习惯手写 JSON Schema,这是最可能踩的第一个坑。

每个 object 上都有 additionalProperties: false tool-todo 的源码解释了原因:「记进日志的快照 必须等于模型认为自己写下的东西,所以嵌套/扩展过的条目形状要在 schema 边界上大声失败,而不是被悄悄 拍平。」在一个事件溯源的系统里,默默接受多余的键就是在污染日志。

output.schema 用同一套形状语言描述返回值。你的工具输出是有类型的,不是自由格式。

output.render 把那个值变成人看到的东西。把机器值和渲染文本分开,意味着模型拿到结构化数据、 UI 拿到一句人话。

execute(args, exec) 是正文。args 进来时已经被注册表校验过 schema 了。

presentCall 描述这次调用在执行之前怎么展示——用户审批或旁观时看到的那张卡片。

校验 schema 表达不了的东西

schema 由注册表负责,剩下的是你的事:

execute(args, exec) {
  const todos = toTodoList(args.todos, allowParallel)  // 去空白、去重、限制只能一个 in_progress
  if (!exec.agent) {
    throw new Error('todo_write requires an owning agent session')
  }
  exec.agent.session.append('todo/write', { todos })
  return Promise.resolve({ todos, counts: /* … */ })
}

tool-todo 校验内容非空、拒绝重复、强制「最多一个 in_progress」——这些 schema 都表达不了。 抛异常是正确的失败方式,错误会传到模型那里。

注意 if (!exec.agent)。工具可能在没有归属 agent 会话的情况下被调用,而这个工具的状态是按 agent 存的,所以它选择拒绝而不是静默空操作。你要有意识地决定:没有 agent 时你的工具该怎么办。

追加 session 事件

exec.agent.session.append('todo/write', { todos })

这是 harness 事件溯源内核的样子。模型看到的一切都记进一份只追加的 session log,resume、fork、search、 replay 全部基于这条流。你的工具不改状态——它追加一个事件,读取方把事件折叠成状态。

tool-todo 注册在可选投影 seam 上的那个折叠函数,很值得当范本读:

apply: (state, event) => {
  if (event.type === 'todo/write') return event.data.todos
  if (event.type === 'turn/start') return null
  return state
},

最后写入者胜、下一个 turn 开始时清空,而且——关键在这——其它所有事件都返回同一个 state 引用, 所以无关事件不会触发重渲染。

package.json

{
  "name": "@deepseek-ai/dsh-tool-todo",
  "type": "module",
  "main": "lib/index.js",
  "types": "lib/types/index.d.ts",
  "exports": {
    ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
    "./package.json": "./package.json"
  },
  "license": "MIT",
  "peerDependencies": {
    "@deepseek-ai/dsh-tools": "…",
    "@deepseek-ai/cordis": "…"
  }
}

最关键的一个选择:harness 的包和 Cordis 本身是 peerDependencies,不是 dependencies。 你的插件必须跑在宿主那份框架上,而不是自带一份。搞错的结果是两个 Cordis 实例,以及服务莫名其妙 解析不到。

type: "module"——只支持 ESM。

挂载它

写完插件不等于它在跑。 必须有东西 insert 一。下面是这一行的形状,取自 dsh-base 自己的 patch 文件:

- insert:
    - id: session-title
      name: '@deepseek-ai/dsh-session-title'
      config:
        fallbackMaxWords: 5
        fallbackMaxBytes: 40
        maxTitleBytes: 80

一行由 id(后面的层用它来寻址)、name(包名)、以及可选的 config(由你的 schemastery schema 校验)构成。把你的写进 profile 的 patch 文件:

# $DSH_HOME/profiles/web/cordis.patch.yml
- insert:
    - id: my-tool
      name: 'your-plugin-package'
      config:
        someOption: true

把包装进 profile,然后验证:

dsh plugin --profile web add your-plugin-package
dsh --profile web --dump-config

如果那一行不在输出里,插件就没在跑——见 插件装上了却始终不加载

我们还不知道的事

@deepseek-ai/* 是官方 scope,你发不进去。截至本文撰写时,仓库里没有记载第三方插件包的命名约定。 Cordis 的 registry 支持 manifest.ecosystem(多级生态)和 manifest.exports(一个包发布多个插件), 所以约定很可能已经存在或正在路上——但我们不会编一个出来,让你之后再改名。

如果你现在就要动手,稳妥的做法是:用一个你能控制、读起来清楚的名字,并做好约定落地后重新发布的准备。

常见问题

能用 default 导出吗?

不能。harness 自带的示例 bundle 里写明:它刻意只暴露具名导出,因为 Loader 的默认解包会丢掉插件的 Config schema。仓库把这件事记录在 postmortem 0001。

inject 和直接 import 一个包有什么区别?

inject 声明的是你需要的服务键——框架会等到那个服务存在才激活你。直接 import 则把你绑死在某个具体实现上。扩展插件应该依赖服务定义,永远不依赖具体的提供者。

第三方插件该用什么包名?

@deepseek-ai/* 是官方 scope,你发不进去。截至本文撰写时,仓库里没有记载第三方插件的命名约定,所以我们不猜。

插件必须是个 class 吗?

不必。Cordis 插件可以是带可选 inject 和 apply 属性的函数,也可以是继承 Service 的类。而 harness 自带工具用的形态是:一个导出 name/inject/Config/apply 的模块。

接着看