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 接口的对象,有两种形态:带可选 inject 和 apply(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 {
// 在这里注册东西
}四个具名导出:name、inject、Config(两次——类型和 schema)、apply。
inject:依赖 seam,不依赖实现
export const inject = ['tools']这声明了你的插件需要哪些服务键。框架会等到这些服务存在才激活你——所以加载顺序是通过服务依赖表达的, 而不是靠手工排启动序列。你永远不用写加载顺序。
背后的架构规则是:扩展插件依赖服务定义,永不依赖具体的提供者。ctx.tools 是一个 seam——定义、
它的提供者、它的消费者三者合起来。你注入这个键;谁提供它不关你的事。
可用的 seam 包括 ctx.tools、ctx.llm、ctx.shell、ctx.sandbox、ctx.approval、ctx.fs、
ctx.web、ctx.subagents、ctx.jobs、ctx.terminals、ctx.storage、ctx.credentials、
ctx.settings、ctx.sessionQuery、ctx.systemPrompt、ctx.codeRuntime、ctx.lsp、
ctx.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 的模块。