DeepSeek Harness 插件开发指南
为什么会有这篇文章:写了两个插件之后我才想起来找插件的脚手架,发现没有;然后我想找个参考资料,仓库里面也没有。但是AI时代了,这不重要,让AI跑一个就好了。以下为Hy3基于仓库内文档和代码总结的(因为限时免费)
适用对象:想在 dsh 上扩展能力的开发者。本文档综合仓库 docs/、packages/ 与 apps/cli 的真实规范,给出从"最小插件"到"打包分发"的完整路径,以及正确性自检方法。
一句话前提:在 dsh 里,一切皆插件(everything is a plugin)。产品能力(工具、模型适配器、子智能体、UI、系统提示词段落、钩子)都是挂在 Cordis 配置树上的插件。你写的任何新能力,最终都落成一个 cordis.yml 里的一行 name 指向的 TypeScript 模块。
1. 心智模型:配置树 + 插件模块
dsh 启动时不写死任何能力。它创建一个空的根 Context,加载 cordis.yml(其本质是 Loader 插件),按"层"逐层把插件挂载到树上。每一层是一个 YAML 数组,用三种形态贡献配置:
- insert: [{ id, name, config? }] | ||
- { id: <已有id>, config: {...} } | id 命中已有行,整体替换其 config(不深合并) | |
- { id: <已有id>, disabled: true } |
示例(本地开发期最简叠加层):
-insert:-id:helloname:'/abs/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'关键约定
- 插件路径必须绝对;patch 文件只贡献配置,不改变 Loader 解析模块的路径基准。
config是整体替换:你在 patch 里 override 某行时,必须把该行需要的每个 key 都写全,不能只写改动的那个。cordis.yml的config下和disabled上允许!!js表达式(如!!js ctx.myAppStartup.port ?? 3080);其他元数据必须是字面量(verify-cordis-config会拒绝元数据里的表达式节点)。注意:是!!js,不是!js。
2. 插件模块长什么样
一个插件就是一个导出 apply 函数的 TypeScript 模块。框架在加载插件时调用 apply,并传入 ctx。
importtype { Context } from'@deepseek-ai/cordis'exportconst name = 'my-plugin'exportfunctionapply(ctx: Context) {// 在这里注册能力console.log('[my-plugin] loaded')}三种形态(选 function 即可,绝大多数场景够用):
// 对象形态exportdefault { name: 'my-plugin', inject: ['tools'], apply(ctx) { /* ... */ } }// 类形态(当你要提供一个 service 时)import { Service, typeContext } from'@deepseek-ai/cordis'exportdefaultclassMyServiceextendsService {static inject = ['tools']constructor(ctx: Context) { super(ctx, 'myService') }}自动清理:通过 ctx 注册的一切(事件监听、工具、定时器)在插件卸载时自动回收。需要显式清理的资源(如网络连接)用 ctx.effect() 提供 disposer:
exportfunctionapply(ctx: Context) { ctx.effect(() => {const timer = setInterval(() =>console.log('heartbeat'), 5000)return() =>clearInterval(timer) // 卸载时执行 })}3. 第一个可运行插件
# 仓库根目录下mkdir -p scratch-plugin/srcscratch-plugin/src/my-plugin.ts:
importtype { Context } from'@deepseek-ai/cordis'exportconst name = 'hello-plugin'exportfunctionapply(ctx: Context) { ctx.effect(() => {console.log('[hello-plugin] plugin loaded!') })}scratch-plugin/cordis.yml(用 pwd 拿绝对路径替换):
-insert:-id:helloname:'/abs/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'启动并观察:
pnpm dsh web --patch ./scratch-plugin/cordis.yml# 终端会打印 [hello-plugin] plugin loaded!4. 插件配置(Schemastery)
任何"两个部署可能想设不同值"的东西,都必须是 config 字段,不能硬编码。
importtype { Context } from'@deepseek-ai/cordis'importSchemafrom'@deepseek-ai/schemastery'exportconst name = 'my-plugin'exportinterfaceConfig {greeting: stringmaxRetries: numbermode: 'fast' | 'accurate'}exportconstConfig: Schema<Config> = Schema.object({greeting: Schema.string().default('Hello'),maxRetries: Schema.number().default(3),mode: Schema.union(['fast', 'accurate']).default('fast'),})exportfunctionapply(ctx: Context, config: Config) {// config 已由 schema 校验并填充默认值,类型安全console.log(config.greeting)}在 patch 行里给 config:
-insert:-id:helloname:'./src/my-plugin.ts'config:greeting:'Hi there'maxRetries:5- 导出
Config必须是Schema实例,不能是普通对象(Cordis 需要 Standard Schema 接口)。 - 校验在加载期运行,非法配置会带着可执行的错误信息失败。
- 改
config会热替换插件(HMR 生效),因为注册都是 effect,旧实例自动清理。
5. 工具插件(最常见)
工具注册在 ctx.tools 上。第一方工具用 defineTool 这个带类型推断的 helper:
importtype { Context } from'@deepseek-ai/cordis'import { defineTool } from'@deepseek-ai/dsh-tools'exportconst name = 'greet-tool'exportconst inject = ['tools']exportfunctionapply(ctx: Context) { ctx.tools.register(defineTool({name: 'greet',description: 'Greet someone by name.',parameters: {name: { type: 'string', required: true, description: 'The name to greet' }, },output: {schema: { type: 'string' },render: (_args, value) => [{ type: 'text', text: value }], },asyncexecute(args) {return`Hello, ${args.name}!` }, }))}inject: ['tools']让 Cordis 等工具注册表就绪后再跑apply。defineTool从parameters推断并校验args;execute返回output.schema声明的值;output.render把它转成面向模型的内容块。- 也可直接
ctx.tools.register()裸 JSON-SchemaToolDefinition(MCP 工具就是这样进来的)。 - 进阶:嵌套 schema、后台执行(
run_in_background)、执行策略钩子、Code Mode、UI 卡片,见docs/cookbook/adding-a-tool.md。
6. 服务与依赖(capability seam)
一个插件可以把能力作为服务暴露给别的插件;别的插件用 inject 声明依赖。
import { Service, typeContext } from'@deepseek-ai/cordis'declaremodule'@deepseek-ai/cordis' {interfaceContext { metrics: MetricsService }}exportdefaultclassMetricsServiceextendsService {static inject = ['llm']constructor(ctx: Context) { super(ctx, 'metrics') } // 'metrics' 即 ctx.metricsrecord(event: string, value: number) { /* ... */ }}消费者:
exportconst inject = ['metrics']exportfunctionapply(ctx: Context) { ctx.metrics.record('tool_call', 1)}依赖行为
inject是必需依赖:服务缺失时插件等待而非运行;服务消失时依赖方自动卸载、服务回来再重载。- 可选依赖:不写
inject,用ctx.get('metrics')?.record(...)在使用点查询。 - 服务隔离:
cordis.yml的group行 +isolate可让不同插件组看到同一服务的不同实例(见docs/user/develop/framework/service.md)。
能力接缝三角色(架构核心):一个可替换能力 = Service Definition(定义)+ Service Provider(实现)+ Consumer(消费) 三件套,缺一不可,只有当三者独立演化时才拆分(shell 三件套是模板)。
7. 钩子与扩展点
新行为应当挂在已文档化的扩展点上,而不是改 agent-loop。核心扩展点(详见 docs/cookbook/extension-cookbook.md 的 feature→mechanism 表):
tools/pre-execute{ kind: 'deny' } 或 { kind: 'ask' } | |
tools/executeexec.signal) | |
tools/post-execute | |
tools/result | |
ctx.systemPrompt.section() | |
ctx.subagentsdsh-tool-subagent | |
ctx.tools.register() | |
session/eventagent.followup()/steer() |
waterfall 监听器必须调用 next() 才能委托;不调用会短路整条链。
8. LLM 适配器插件
新增模型供应商 = 继承 LlmAdapter 并通过 registerAdapter 注册路由:
importtype { Context } from'@deepseek-ai/cordis'exportconst name = 'llm-my-provider'exportconst inject = ['llm']exportfunctionapply(ctx: Context) { ctx.effect(() => ctx.llm.registerAdapter( ['my-provider'], // 路由键(全局唯一)newMyAdapter(), // 继承 LlmAdapter ))}- 参考实现:
packages/llm/llm-deepseek、packages/llm/llm-pi-ai。 - 多模型各自不同 baseURL/key = 多个 provider route;
llm-pi-ai的Config.providers是以 route 为键的字典,每个 route 自带baseURL+apiKeyEnv(凭据引用,不是密钥本身)。 - 路由键全局唯一,撞键会导致整层注册原子失败。
9. UI 插件
Host 侧(最简):监听 session/event 流(助手 token、turn/step 边界、工具活动),用 agent.followup()/agent.steer() 把输入喂回去。
exportconst inject = ['agents']exportfunctionapply(ctx: Context) { ctx.on('session/event', (_session, event) => {if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {render(event.data.chunk.text) } })}Client 侧(Web 业务行):注册 ConversationNodeDefinition + conversation.chat.node 的 keyed renderer,遵循 docs/cookbook/adding-a-conversation-node.md。
- 客户端包要
dsh.client声明、exports["./client"]、用共享 tsdown 预设(见packages/client/AGENTS.md)。
10. 预设(agent preset,零代码)
预设是"把一个智能体的 persona + 工具清单 + 系统提示词"打包成一份 cordis.yml,由 agent-presets 行发现。这是不需要写 TypeScript 的一类扩展:
-id:my-presetname:'@deepseek-ai/cordis-plugin-group'group:trueconfig:-name:'@deepseek-ai/dsh-tool-subagent'config: { /*三个专家实例各带persona*/ }# ... 其他工具参考:packages/preset/agent-presets、examples/*/cordis.yml。
11. 打包与安装(bundle vs profile)
两个概念、两份 manifest(都写在 package.json 的 dsh 键下):
- bundle = 你编写并分发的 npm 包,声明
dsh.bundle,贡献一个cordis.patch.yml配置层。 - profile =
$DSH_HOME/profiles/<name>下的目录,声明dsh.profile.bundles有序列表,决定组合哪些 bundle。
最小 bundle 包结构:
hello-plugin/├── package.json # 声明 dsh.bundle├── cordis.patch.yml # 被 profile 应用的层└── index.js # patch 行引用的插件模块package.json:
{"name":"dsh-hello-plugin","version":"0.1.0","type":"module","main":"index.js","files":["index.js","cordis.patch.yml"],"dsh":{"bundle":{"patch":"./cordis.patch.yml"}}}cordis.patch.yml(用包名而非路径,让 Node 解析安装后的代码):
-insert:-id:helloname:dsh-hello-plugin安装到 profile(首次会初始化 profile,并以 dsh-base 为首个 bundle):
dsh plugin --profile demo add ./hello-plugin# 等价于 pnpm add,并把该 bundle 追加到 dsh.profile.bundles层叠顺序(后者赢、整行替换):
- 1
dsh.profile.bundles列表顺序(先dsh-base,再按添加顺序) - 2profile 自身的
cordis.patch.yml - 3机器级
$DSH_HOME/cordis.patch.yml - 4每个
--patch <path>overlay(按 argv 顺序)
验证组合而不启动(最接近"正确性检查"的工具):
dsh --profile demo --dump-config # 打印组合树,标注每行来源文件与 overlay 变更卸载:dsh plugin --profile demo remove dsh-hello-plugin(同时移除依赖与层)。
从 git 安装有个坑:git 装的是源码不是构建产物,需要作者提供 prepare 脚本自包含构建,且用户需在 pnpm-workspace.yaml 里 allowBuilds 授权(pnpm ≥10 默认拒绝)。不想让用户授权,就发布到 npm(发布时 lib/ 已构建)或发 tarball。
12. 正确性与质量闸门
dsh 没有一键生成正确骨架的命令(见配套文档 plugin-scaffold-design.md),所以"正确"靠你内化约定 + 闸门校验。必须知道的硬约束:
- 1包名与 peerDependency:每个包是
@deepseek-ai/dsh-<name>;@deepseek-ai/cordis同时出现在peerDependencies和devDependencies(同版本范围)。vendored 包 rescoped 且private: true。 - 2resolver manifest 依赖:在
cordis.yml里用绝对/相对路径引用的"裸插件(bare plugin)",必须出现在其 resolver manifest 的dependencies里——verify-cordis-config强制校验。把包放进packages/<group>/<pkg>(pnpm workspace + tsconfig wildcard 自动发现)即可满足。 - 3ESM 全程:
"type": "module",本地相对导入用显式.ts后缀(export * from './types.ts');dshCLI 源码启动走 tsx 的 ESM-only 钩子,被它加载的模块必须保持 ESM。 - 4注册即 effect:所有贡献走
ctx.effect()/ctx.on()。 - 5可执行闸门(
pnpm run hygiene内含): - 0
verify-cordis-config:校验 Loader 元数据里没有表达式节点、裸插件依赖齐全。 - 0
constraints/check-workspace-constraints.ts:校验private:true、版本与根一致、type:module、main/types/exports形态、files清单等包不变量。 - 0
knip(死代码)、publint、verify-package-invariants、verify-built-package-invariants。 - 6README 必需结构:服务 API、config、事件、扩展点、设计笔记 + gated 的
Model Experience段 +Known Limitations and Deferred Work段(除非加入 allowlist)。
新包标准校验流程:
pnpm installpnpm run doc-syncpnpm run constraints && pnpm run typecheck && pnpm run lintpnpm run build && pnpm run hygiene13. 开发者自检清单(避坑)
- 插件模块导出了
name与apply(或 default 对象/类)。 - 依赖的服务都写进
inject;可选依赖用ctx.get()。 - 所有部署可变值都是
Config字段(Schemastery),没有硬编码TIMEOUT = 30000。 - patch 里 override 已有行时,
config写全了所有 key(整体替换语义)。 cordis.yml里表达式用!!js而非!js,且只出现在config/disabled上。- bundle 包
package.json声明了dsh.bundle.patch,且cordis.patch.yml用包名引用而非裸路径。 - 裸路径插件已加入 resolver manifest 的
dependencies。 - 本地先
dsh --profile <x> --dump-config确认层叠正确,再启动。 - 新行为挂在扩展点上,没有改
agent-loop。 - 跑过
pnpm run hygiene全绿。
14. 参考文档索引
- 新手:
docs/user/develop/basic/(第一个插件 / 工具 / 配置 / 打包)、docs/cordis-tutorial/ - 框架:
docs/user/develop/framework/(service、events)、docs/cordis-primer.md、docs/architecture.md - Cookbook:
docs/cookbook/(extension-cookbook、adding-a-package、adding-a-tool、adding-an-llm-adapter、adding-a-conversation-node) - CLI:
apps/cli/reference/README.md - 真实模板:
packages/subagent/tool-subagent(工具+子智能体)、packages/llm/llm-pi-ai(多模型适配器)
夜雨聆风