DSH plugin 开发常见错误:插件不加载、停在 PENDING、execute 契约越界与卡片渲染非纯函数的修法
DSH plugin 的开发期错误有个共同特征:绝大多数不报错,只是静默失效。 导出形态混写会让插件压根不被识别,inject 漏写会让它停在 PENDING,execute 返回了散文会让 Code Mode 拿不到字段,卡片里读了一次文件会让会话回放崩溃——这些都不是语法错误,编译器不会提醒你。本文按「症状 → 根因 → 修法」把高频错误整理成一张可对照的速查表。无论你叫它 DSH插件 还是 DeepSeek插件,这些静默失效的坑完全一样。
DSH plugin 开发错误速查表
先按你看得见的症状定位,再跳到对应小节。(依据官方「你的第一个插件」、「工具编写参考」)
| 症状 | 根因 | 修法 |
|---|---|---|
| 插件完全没被识别、日志无任何输出 | 导出形态与写法不匹配 | 函数形态具名导出、对象/类形态默认导出,不混写 |
| 一直停在 PENDING | inject 漏写,或必需依赖写成 ctx.get() | 必需依赖写进 inject |
| 状态转 FAILED、启动日志有异常栈 | apply 抛异常 | 移除长阻塞与一次性副作用 |
| 工具有结果但调用方拿不到字段 | execute 返回了散文而非 canonical 值 | 只返回一个符合 output.schema 的值 |
| 回放历史会话时卡片崩、卡片内容与当时不一致 | presenter 不是纯函数 | 把 I/O 与时钟移出 presentCall / presentResult |
| 装完插件不生效、配置树里没有它 | bundle 漏了 cordis.patch.yml 或 files 没带 | 补 dsh.bundle.patch 与 files |
| 装完宿主启动即崩 | git 分发缺构建产物 | 发 npm 包或让作者补 prepare |
| 卸载后定时器还在跑 | 手动资源没交给 ctx.effect | 用 ctx.effect() 交出处置器 |
DSH plugin 加载与状态类错误
插件「没日志、停在 PENDING、转 FAILED」都属于加载阶段,查的是导出形态、inject 声明与 apply 副作用这三处。
错误一:插件压根没进状态机——导出形态混写
症状:配置树里有条目,但插件没有任何日志、也查不到 Fiber 状态。
根因:框架只按一种形态读取你的模块。函数形态必须具名导出,对象形态与类形态必须默认导出(来源):
// 函数形态:具名导出。
export const name = 'my-plugin'
export function apply(ctx: Context) { /* ... */ }
// 对象形态:默认导出。
export default { name: 'my-plugin', inject: ['tools'], apply(ctx) { /* ... */ } }
最常见的混写是同时写 export default 和具名 apply——框架按默认导出读,另一个 apply 被静默忽略;反之亦然。
修法:一个模块只保留一种形态,把 name、inject、apply 放在同一个导出里。完整的形态选择见 DSH plugin 开发规范。
错误二:停在 PENDING——inject 漏写
症状:插件状态长时间停在 PENDING,apply 里的代码一行都没执行。
根因:PENDING 的官方含义是「已声明,但所需依赖未就绪」。声明了 inject 的插件会等待所有必需服务就绪后才执行 apply;所以反过来,inject 里写了一个根本不存在的服务名,插件就永远等不到(来源)。
第二种更隐蔽的根因:把必需依赖写成了可选查询。
// 错误:必需依赖写成可选查询,插件会带着半残状态进入 ACTIVE。
export function apply(ctx: Context) {
const tools = ctx.get('tools')
tools?.register(/* ... */)
}
// 正确:必需依赖写进 inject,缺失时插件不加载。
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(/* ... */)
}
修法:判断标准是「服务不在就干不了活」还是「服务在就多做一步」——前者写进 inject,后者才用 ctx.get()。装上后一直不激活的排查见 DSH plugin 装了不生效。
错误三:状态转 FAILED——apply 抛异常
症状:Fiber 状态进入 FAILED,启动日志里有异常栈。
根因:apply 里同步抛出的异常会直接让插件加载失败。高频写法有三种:
apply里做长阻塞:apply是同步初始化阶段,网络请求、大文件读写都不该放这里,应交给后台任务。- 依赖执行顺序:
inject保证服务就绪,但不保证你注册的回调按你期望的顺序被调用。 - 只允许跑一次的全局副作用:依赖服务消失时插件会自动卸载、服务恢复后重新加载,
apply会被再次执行(来源)。
修法:apply 保持轻量且可重复执行。清掉这三类写法之后仍报错,再用 本地调试 的 --dump-config 确认挂载的确实是你的那份代码。
DSH plugin 契约与渲染类错误
工具与卡片的错误都不报错,只会让字段拿不到或回放崩掉——契约看 execute 的返回值,渲染看 presenter 的纯度。
错误四:execute 契约越界——返回了散文
症状:工具在界面上有输出,但 Code Mode 里 await tools.xxx() 拿不到 id 和字段,只能拿到一段文字。
根因:execute 的契约是只返回一个 canonical JSON 值,由注册表快照、校验、冻结后交给 output.render(args, value) 生成模型可见内容。直接从函数体返回 content blocks、或让调用方去 parse 散文,都是越界(来源)。
// 错误:把模型可见的散文当成返回值。
async execute(args) {
return [{ type: 'text', text: `已写入 ${args.path}` }]
}
// 正确:返回结构化规范值,散文交给 render。
output: {
schema: { type: 'object', properties: { path: { type: 'string' }, bytes: { type: 'number' } } },
render: (args, value) => [{ type: 'text', text: `已写入 ${value.path}(${value.bytes} 字节)` }],
},
async execute(args) {
const bytes = await write(args.path)
return { path: args.path, bytes }
}
同一节还有四条硬规则,验收时逐条过:
- 抛异常 =
isError:基础设施故障才 throw;非理想的业务结果照常放进规范值里(白名单的card渲染意图也一样)。 - 遵守
exec.signal:取消触发就停掉在途工作。 - 注册后不改定义:注册是「借走你的只读定义」,不要再改 schema 或替换回调;要热替换就释放该 effect 再重新注册。
- 参数是校验过的只读输入:
defineTool会先按 schema 校验模型生成的参数,execute里拿到的已是推断类型;DSL 表达不了的约束(非空字符串、正数、跨字段规则)才需要你手写校验。
工具侧的完整写法见 DSH plugin 怎么写工具插件。
错误五:卡片渲染非纯——回放会话就崩
症状:实时运行正常,回放历史会话时卡片崩掉或显示内容与当时不一致。
根因:presentCall / presentResult 在实时流和会话日志回放时都会执行,所以必须是 args(加结果)的纯函数——不许做 I/O、不许读会话状态、不许读时钟或随机数。想在 presentCall 里读文件旧内容、读当前工作目录,就是踩了这条线(来源)。
同类错误:把 UI 专属格式混进模型结果。 fenced console 代码块、inline diff、相对化路径——这些都不该为了服务界面而塞进 canonical value 或模型可见内容里。output.render 只负责模型侧散文,卡片数据由 presentationMeta 与 presenter 负责。
修法:需要结果期的既有事实(例如写入的 hunk),用 output.presentationMeta(args, value) 从同一个规范值投影出可回放的 JSON,交给 presentResult 用;绝不要为了渲染卡片去读文件。顺带一个缓冲:defineTool 对显示路径是软校验,参数畸形或来自旧日志时 presenter 返回 undefined 走通用卡片兜底,而不是抛异常——所以显示层永远不会把回放打崩。
DSH plugin 分发与安装类错误
安装退出码为 0 不代表插件可用:配置层可能没进 profile,构建产物可能压根不在包里。
错误六:装完不挂载——bundle 漏了 patch 文件
症状:安装成功、退出码为 0,但配置树里搜不到插件条目。
根因:分发的插件以组合包(bundle)形式交付,package.json 用 dsh.bundle.patch 指向 cordis.patch.yml,配置层才是把自己 insert 进配置树的那一步。只发 index.js、或者 files 里没带 cordis.patch.yml,安装方就永远拿不到配置层。
{
"files": ["index.js", "cordis.patch.yml"],
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}
修法:发布前确认 files 含 patch 文件,装完用 dsh --profile demo --dump-config 验证条目真的进了树。另一条相关坑:patch 是按整行替换的,不做深合并,所以覆盖某个字段时要把整行配置写全。打包流程见 DSH plugin 打包成 bundle。
错误七:装完启动即崩——git 分发缺构建产物
症状:github:owner/repo 安装,pnpm 装完无报错,宿主重启后立刻 ERR_MODULE_NOT_FOUND 退出。
根因:git 分发拉的是源码,不跑构建。仓库如果 .gitignore 掉了 lib/,而 main 指向 lib/index.js,安装后就只有源码没有入口产物。这类错误在安装阶段完全看不出来——只看安装命令的退出码远远不够。
修法:三条路按优先级——① 发布到 npm(带构建产物的 tarball 安装时不需要构建权限);② 作者在 package.json 里补 prepare 脚本,用户在 profile 里用 allowBuilds 放行;③ 临时用 pnpm pack 的 tarball 分发。完整对比与操作见 DSH plugin 发布到 npm。
DSH plugin 资源清理类错误
卸载不等于停止:绕过 ctx 创建的资源必须自己交回框架。
错误八:卸载后还在跑——手动资源没交回框架
症状:插件卸载了,定时器还在打日志、连接还开着。
根因:通过 ctx 做的注册会被框架自动追踪并在卸载时撤销——ctx.on、ctx.tools.register、ctx.llm.registerAdapter、ctx.effect 都在其列,不需要你手写 removeListener / clearInterval(来源)。会漏的是绕过 ctx 手工创建的资源:网络连接、文件句柄、自己 setInterval 的定时器。
修法:把这类资源包进 ctx.effect() 并返回处置器。
export function apply(ctx: Context) {
ctx.effect(() => {
const timer = setInterval(() => console.log('heartbeat'), 5000)
return () => clearInterval(timer) // 插件卸载时执行。
})
}
一个必须记住的细节:处置器按注册顺序逆序开始调用,但多个异步处置器会并发执行、不保证逐个完成。有顺序依赖的清理(先断流、再关连接)必须合并进同一个 ctx.effect(),拆成两个就失去顺序保证。
把 DSH plugin 错误挡在发布之前
上面八类错误里,只有第三类会给你异常栈,其余七类都是静默失效——所以「能装上」绝不能当作验收标准。 发布前的最小动作组合:
- 用
dsh --profile demo --dump-config确认插件条目进了配置树(挡错误六)。 - 在干净 profile 里
add一次,重启宿主确认不崩(挡错误七)。 - 卸载一次,看日志与连接是否真的停(挡错误八)。
- 让工具在 Code Mode 里被调用一次,确认拿到的是结构化字段(挡错误四)。
规范条目逐条核对见《DSH plugin 开发规范》,调试通道与配置分层见《DSH plugin 本地调试》,配置项的静默兜底问题见《DSH plugin 配置项怎么定义》。参照真实插件的目录与 bundle 声明,可以在 DSH Plugin Hub 里找同类插件对照。
常见问题
**DSH plugin 压根没进状态机时,先查导出形态**:函数形态必须具名导出(export const name + export function apply),对象形态和类形态必须默认导出(export default)。两种混写时框架只认一种,另一半声明被静默忽略——最常见的写法是既写 export default 又写具名 apply,结果插件根本没被当作插件识别(来源:官方「你的第一个插件」)。
**DSH plugin 停在 PENDING 的含义是「已声明,但所需依赖未就绪」**——也就是 inject 里声明的服务还没出现。**要么必需依赖漏写进 inject,要么把「服务不在就干不了活」的依赖写成了 ctx.get()**。后者更隐蔽:插件会带着半残状态进入 ACTIVE,表现为「装了但功能不生效」(来源:官方「插件与生命周期」)。
**DSH plugin 的 apply 抛出异常时插件转入 FAILED,异常栈会出现在启动日志里。** 排查时要看两点:**apply 里有没有做长阻塞或一次性全局副作用**,以及**执行顺序依赖有没有写死**。声明了 inject 的插件在依赖服务消失时会自动卸载、服务恢复后重新加载,所以 apply 必须可重复执行(来源:官方「插件与生命周期」)。
**DSH plugin 工具的 execute 只应返回一个 canonical JSON 值,且必须符合 output.schema。** 不要从 execute 里返回 content blocks、也不要让调用方去解析散文字符串拿 id 和字段——模型可见的散文归 output.render 负责。抛异常或返回不合 schema 的值都会被判为 isError;同时要遵守 exec.signal,取消了就停掉在途工作(来源:官方「工具编写参考」)。
**DSH plugin 卡片在回放会话时崩,是因为卡片呈现函数(presentCall / presentResult)不是纯函数。** 它们在实时流和会话日志回放时都会执行,因此必须是 args(加结果)的纯函数:不许做 I/O、不许读会话状态、不许读时钟或随机数。想拿文件的旧内容或工作目录,那属于结果元数据或 UI 适配器的职责,不该进 presenter(来源:官方「工具编写参考」)。
相关术语
- canonical value(规范值)
- canonical value 是 DSH plugin 工具 execute 返回的唯一权威值,需匹配 output.schema。注册表会对它做无损 JSON 快照、校验、冻结,再交给 output.render 生成模型可见内容;调用方据此拿到结构化字段,而不是解析散文。— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-tool.md
- presentCall / presentResult
- presentCall / presentResult 是 DSH plugin 工具声明的两个 UI 呈现投影:presentCall 生成 PENDING 卡片,presentResult 生成完成卡片。二者在实时流与会话回放时都会运行,必须是纯函数,且只返回 card 标记的渲染意图。— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-tool.md
- PENDING
- PENDING 是 DSH plugin 的 Fiber 状态机中的第一个状态,含义是插件已声明但所需依赖未就绪。停在 PENDING 通常说明 inject 声明的服务不存在,或必需依赖被误写成可选查询。— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/framework/index.zh.md
- bundle(组合包)
- DSH plugin 的分发形态:package.json 用 dsh.bundle.patch 指向 cordis.patch.yml,由该配置层把自己的插件条目 insert 进配置树。发布时 files 必须包含 patch 文件,否则安装方拿不到配置层。— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/basic/publish.md
来源
- DeepSeek Harness 官方文档 - 你的第一个插件(三种形态与自动清理)· deepseek-ai
- DeepSeek Harness 官方文档 - 工具编写参考(execute 契约与卡片纯函数)· deepseek-ai
- DeepSeek Harness 官方文档 - 插件与生命周期(Fiber 状态机)· deepseek-ai
- DeepSeek Harness 官方文档 - 插件发布(bundle 与 profile)· deepseek-ai