把 DeepSeek Harness 的插件机制逐层拆开:Cordis 的上下文与生命周期、profile 的层叠、能力进入模型的四种表面、客户端槽位的挂载契约。
| 层 | 是什么 | 实际形态 |
|---|---|---|
| plugin | 实现层:做一件事 | 导出 name / inject / Config / apply(ctx, config)。在 apply 里提供 Service、注册 Tool、注册 Command、贡献提示词段 |
| bundle | 打包层:把一批插件行打成一个 npm 包 | package.json 声明 dsh.bundle.patch 指向 cordis.patch.yml,文件里用 - insert: 列出几十行插件 |
| profile | 组合层:一次启动到底装了什么 | $DSH_HOME/profiles/<name>,package.json 里 dsh.profile.bundles = [dsh-base, dsh-web-app],外加自己的 patch |
DSH 没有硬编码的功能列表。功能集合由 profile 决定,功能实现由 plugin 决定,中间那层 bundle 只是把插件行打包发行。
同一批插件可以组成不同产品:web profile 多一个 dsh-web-app,headless profile 只有 base。界面不是“主程序的一部分”,是组合出来的。
启动分两半:前半段决定“加载什么”,后半段决定“什么时候可用”。这两件事经常被混为一谈,也是排障时最容易走错的地方。
加载什么。这一层落空,插件根本不在树里,后面所有现象都不用看。
什么时候可用。行顺序不承载加载语义,写在文件里靠前不代表先启动——这是 cordis.patch.yml 注释里反复强调的一条。
| 插件 | 一个实现 Service 的对象:带可选 inject 与 apply 的函数,或 Service 子类 |
| 上下文 | 服务的容器。一个服务占一个稳定 key,别人按 key 取用,不导入具体实现 |
| inject | 声明所需服务,等它们就绪才启动。加载顺序由依赖表达,不靠人工编排 |
| 事件 | 类型化通信,六种分发模式,分别承担通知、包装、扇出、顺序执行、短路 |
| 可逆注册 | 提示词段、工具 schema、适配器、监听器都在 reload 与 teardown 时撤销 |
四件东西都挂在插件身份上,apply 只是执行体。类插件、对象插件换的是写法,不是契约。
声明了但服务没提供 → 插件停在 inactive,apply 根本不执行,也不报错。服务被注销 → 依赖它的插件先卸载,等新实例出现再重启。
ctx.get('fs') 拿不到就返回 undefined,不抛错。DSH 里可选能力走这条:有审批服务就走审批,没有就退化为 fail-closed。
“插件写进 YAML 了却没生效”,多数不是加载失败,是停在 inactive。先看它 inject 的服务谁提供,而不是翻插件代码。
服务一注销,依赖者先下线。这条规则让“换掉一个 provider”不需要重启进程,也解释了为什么热更新能成立。
| 方法 | await | 顺序 | 返回值 |
|---|---|---|---|
| emit | 否 | 注册序 | 无 |
| waterfall | 否 | 注册序,环绕 | 有,可改写 |
| parallel | 是 | 并行 | 无 |
| serial | 是 | 顺序 | 有 |
| bail | 否 | 顺序至命中 | 有 |
| once | — | 仅一次 | — |
监听器收到 (...args, next)。调 next() 往下走;不调直接返回就短路。它像中间件,可以改参数、改结果、拦下来。
tools/pre-execute、tools/execute、tools/post-execute、system-prompt/assemble、internal/update 全是 waterfall。
权限门、提示词改写、热更新的否决,都挂在这一种分发上。要加“能拦住动作”的能力,只能走这里。
Fiber 是插件在运行期的记录:依赖满没满、配置是什么、有哪些 effect 要清。它的 state 直接对应你看到的现象。
| 0 inactive | 未激活 | 依赖未满足,或已卸载 |
| 1 loading | 加载中 | reload 正在执行 |
| 2 active | 活跃 | apply 已完成 |
| 3 error | 启动失败 | error 里存着原因 |
| 4 disposed | 已销毁 | effect 已逆序清理 |
| 5 unloading | 卸载中 | 清理尚未结束 |
报“插件没生效”之前,先看它停在哪个 state。inactive 是等依赖,error 是代码炸了,两者修法完全不同。
Config 是 Schemastery 的 schema,Cordis 在启动前同步校验,失败抛 ValidationError 并聚合一整批 issue。所以 apply 拿到的一定是良构配置,作者不必再判空。
default 意味着可缺省,归一化后一定有值;required 意味着缺了就算失败。两者写错,现象是启动直接进 error,而不是运行中出怪值。
字段带 .volatile(),插件页就能改。写法是每次调用点 config.x.get(),不是加载时读一次。保存落到 $DSH_HOME 的设置文档,重启仍在;重置则回到 patch 里的部署默认值。
同步校验是硬约束:配置校验若返回 Promise,框架直接抛 Async config validation is not supported。
① 行由 id 寻址;② config 整体替换,不深合并;③ disabled 可以写成 !!js 表达式按环境求值;④ 行顺序不承载加载语义。
改一个字段要把这一行需要的 key 全部重述。这是“任何一行都能被上层改掉”的必要代价,也是最常见的踩坑点。
除了 bundle / profile / home / --patch,启动器还会追加一层派生 flag patch(例如遥测开关),排在最后。
同一个插件,可以只占一张面孔,也可以同时占几张。决定它“是什么”的,不是包名,是 apply 里调了哪些 ctx.*。
| 表面 | 注册点 | 模型看到什么 |
|---|---|---|
| 工具 | ctx.tools.register(defineTool(…)) | 名字、描述、参数 schema、输出 schema;调用后拿到结构化结果 |
| Skill | ctx.skills.registerProvider() | SKILL.md 的 name 与 description 进目录,正文按需加载 |
| 命令 | ctx.commands.register() | 斜杠命令由人敲,不占模型上下文 |
| 提示词段 | ctx.systemPrompt.section() | 直接拼进 system prompt 的文本,每轮都在 |
想加能力时,先选面孔再写代码:要模型主动调用 → 工具;要模型读一段方法 → Skill;要人触发 → 命令;要无条件生效 → 提示词段。
Skill 在 DSH 里也只是插件贡献的一个注册表项。它和工具、命令共用同一套加载、配置、卸载机制,没有特殊通道。
执行、模型可见渲染、界面呈现是三件分开的事。混淆它们,工具就会在某一端表现失常。
ctx.on('tools/pre-execute', …) 返回 { kind: 'deny', reason } 就是拒绝,调 next() 就是放行。这是一个 waterfall,可组合。
isConcurrencySafe 决定同一个工具能否被并行调用。只读工具可以开,写文件的不行。
schema 一改,KV Cache 前缀失效,缓存命中率与成本立刻变化。这不是性能优化,是设计要求。
只有 host 能力的包不得声明 ./client。声明了却发不出文件,就是“装得上、启动正常、界面永不出现”。
lib/client.js 是一个惰性 CJS 工厂:window.__ModuleLoader__.load({ id, factory })。宿主按包名发现 dsh.client 声明,把这个文件喂给页面。
react 由浏览器平台的模块表提供;所有 @deepseek-ai 客户端包只以 import type 出现,编译后被擦除。所以客户端半边不背 SDK 版本,跟着宿主走。
插件作者不需要为每个宿主版本打一份客户端包,也不会因为 SDK 版本错配而白屏。
槽位名本身就是坐标。想加一块界面,先在这张表里找位置,而不是想办法往 DOM 里塞东西。
前缀是区域,后缀是位置:settings.* 设置页,conversation.input.* 输入区,session.header.* 会话头。读到名字基本能猜到它出现在哪。
这是某一版的清单,0.2.x 又加了菜单位。名字会增删,机制不变:宿主声明槽,插件往里注册条目。
第四条是这套设计里最实用的一个决定:槽名拼错当场炸。相比之下,“装上了但界面不出现”是最难查的一类故障。
这是整套设计成立与否的验证点:只有当“随时装卸”真的成立,才敢让模型自己写插件、装进正在运行的进程。
0.2.1-alpha.1 起,插件管理页有「让 Agent 创建插件」入口,草稿会保留进创造模式;0.2.2-rc.1 支持更新第三方插件包,热更新失败自动回滚。
volatile 配置(不重启改参数)+ effect 可逆注册(改完能干净卸)。少了任何一个,这条链路都会在第二次修改时烂掉。
“能装上”和“该装”之间没有任何自动闸门。这五项挡的是到货即坏,不是恶意代码。
插件机制一旦开放,权限模型必须先想清楚:同权执行意味着插件作者和用户之间需要一层信任判断,靠校验清单替代不了。
--patch 覆盖层只加载 host 半边:模块解析够不到包级声明。要测界面,必须真的装进 profile。
从 1 往下走,每步只排除一种可能。先看树,再看状态,最后才看界面——反过来查会浪费很多时间。
上一版把五条当做法讲。拆到这一层能看到:每一条背后都有具体机制,抽掉一个,那条就退化成约定。
| 原则 | 支撑它的机制 | 对应页 |
|---|---|---|
| 槽位化 | 具名 slot + ctx.slots.inject/register + key 定位贡献对象 | P12 – P14 |
| 依赖声明 | inject 硬依赖驱动激活;ctx.get 软依赖退化 | P04 |
| 注册可逆 | ctx.effect 登记 disposer,fiber.dispose 时逆序清理 | P06 |
| 装配分层 | patch 按 id 寻址、五层叠加、config 整体替换 | P08 |
| 安装即验收 | 五项结构校验 + --dump-config 可观测合成结果 | P15 – P16 |
反过来说,一个平台如果只提供“配置文件里加一行”和“卸载时调个清理钩子”,它就已经具备这五条里最难的两条;剩下的靠纪律就能补。
这套机制的代价是理解成本:得先懂 Cordis,才看得懂一个插件为什么没生效。
机制描述以 0.1.0-rc.6 的代码渊源为准,版本与发布行为以 0.2.2-rc.1 为准;两者之间有破坏性变更,槽位名与字段以实际安装版本为最终依据。