DeepSeek Harness 最小插件怎么写:apply、ctx 与插件生命周期
deepseek-harness 这个仓库的 GitHub description 只有一句话:DeepSeek Harness: Everything is a Plugin.。这句话在仓库里至少还出现了三次——README.md:7 说 dsh 采用 everything is a plugin 的架构、由 Cordis 驱动;AGENTS.md:3 说它是建立在 vendored Cordis 之上的插件式 agent harness;docs/architecture.md:11 把话说得最狠:产品的每一部分都是插件,包括模型适配器、工具注册表、会话日志、以及 agent loop 本身,所以每一部分都可以从配置里替换。同一份文档 :13 还补了一句「没有一个特权 core 可以打补丁」。
口号听多了容易麻木,不如直接去看这个体系里最小的那个单位长什么样。
先把限定条件说在前面:截至 2026-08-16 我们采集时,这个仓库建立于 2026-08-13,前后只差三天;根 package.json 的版本是 0.1.0-rc.5,/releases 接口返回的是空数组,一个 Release 都没有发过。README.md:9-11 有一个独立的 Developer preview 小节,里面用全大写写着 THERE WILL BE COMPATIBILITY-BREAKING CHANGES.。所以下面提到的每一个 API 名字、每一个默认值,都可能在你读到这篇文章时已经变了,请以仓库当前内容为准。
三行代码的插件
docs/cordis-tutorial/01-first-plugin.md:11-19 给出的 hello.ts 全文如下:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'hello'
export function apply(ctx: Context) {
console.log('hello from my first plugin')
}
配套的 cordis.yml(01-first-plugin.md:27-29)只有一行:
- name: './hello.ts'
两个导出里,name 是可选的(01-first-plugin.md:21),只是展示用的元数据,用来在诊断里给插件贴标签。真正必需的只有 apply。而 cordis.yml 里的 name 字段是另一回事——01-first-plugin.md:31 说它是模块 specifier,可以写相对路径,也可以写 npm 包名,loader 会把列表里的每一条都挂上去。
这里有一个初看别扭的设定:01-first-plugin.md:31 明写这些条目是并发启动的,条目在文件里的位置不保证谁先加载,顺序来自服务依赖(inject),而不是行序。同样的话在 packages/bundle/base/cordis.patch.yml:1-13 的开头注释里又出现一次:「Row order carries no load semantics」,激活由服务可用性驱动,分组只是给读者看的。习惯了「配置从上往下生效」的人,第一次读到这里通常要愣一下。
教程给出的运行方式在 docs/cordis-tutorial/index.md:26-34:它在仓库内 tmp/cordis-tutorial 目录里做实验(tmp/ 已被 gitignore),每一章都用同一条命令 node --import tsx ../../vendor/cordis/bin.js。index.md:36 解释这个一文件启动器做三件事:创建一个根 Context、挂载 Loader 插件、让它从当前目录加载 ./cordis.yml。vendor/cordis/bin.js 本身只有 14 行,内容就是新建 Context、把 ctx.baseUrl 设成当前工作目录、await ctx.plugin(Loader),再创建一个 @deepseek-ai/cordis-plugin-include 条目指向 ./cordis.yml。顺带一提,index.md:15 写明这一路教程不需要 API key。
apply 拿到的那个 ctx 是什么
它不是一个普通对象。docs/cordis-api/context.md:10 说得很直白:context 是一个 proxy,普通属性读取会走 service resolver。所以 ctx.tools、ctx.llm、ctx.sessions 这类写法,读的不是字段,是「当前作用域下解析出来的那个服务实现」。
docs/cordis-primer.md:10 把这层意思归纳成一条:context 是服务的仓库,一个 service 认领一个稳定的 ctx.<key>,别的插件按 key 找服务,而不是 import 具体实现。对应的读写 API 实现在 vendor/cordis/src/reflect.ts:ctx.get(name, strict?) 在 :17(文档写 strict 默认为 true,只返回其提供者 fiber 当前处于 active 的实现),ctx.set 在 :29(只有提供该服务的 fiber 能 set,对未提供的名字 set 会抛),ctx.provide(name, value) 在 :44,返回一个注销该服务的 disposer。
context 还能派生:ctx.extend(meta?) 在 vendor/cordis/src/context.ts:99,ctx.isolate(name, label?) 在 :121,ctx.intercept(name, config) 在 :139。context.md:10 强调这三个方法创建的是作用域化的子 context,不改父 context。vendor/cordis/src/context.ts 全文 146 行,可以整篇读完。
顺便说一个容易踩的边界:docs/cordis-tutorial/03-services.md:94 写明 service 名字在一个应用内是一个扁平命名空间,harness 已经占用了 tools、llm 这类朴素名字。我们用正则从 packages/ 的源码里数了一遍(排除 tests/ 目录与各类测试文件),super(ctx, '<name>') 得到的不重复服务名有 67 个,class X extends Service 出现 66 处。这个数字是我们自己数的,与任何文档口径无关,只用来说明「朴素名字确实很挤」。
三种形态与那条状态机
01-first-plugin.md:55-75 列出 Cordis 接受的三种插件形态:函数插件、带 apply 方法的对象插件、Service 子类的类插件。02-lifecycle-and-effects.md:64 补了一句很关键的话:函数插件不需要 apply 方法,apply 只有对象形态才必需;而 ctx.plugin(heartbeat) 从代码里挂一个函数插件,和 YAML loader 对每个配置条目做的是同一个操作。
ctx.plugin() 的返回值叫 fiber,02-lifecycle-and-effects.md:64 的定义是「一个已加载插件实例的运行时句柄」。它的状态机图在 02-lifecycle-and-effects.md:73:
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
↘ FAILED
:77-80 逐个解释:PENDING 是已声明但所需服务尚不可用;LOADING / ACTIVE 是 apply 运行中与已完成;FAILED 是 apply 或配置校验抛错;UNLOADING / DISPOSED 是 disposer 运行中与全部拆完。这六个状态在源码里是一个 const enum,定义在 vendor/cordis/src/fiber.ts:147-154,成员的书写顺序实读为 PENDING, LOADING, ACTIVE, FAILED, DISPOSED, UNLOADING——与教程那张图上的排列次序不同。两处位置都在上面,含义解释是一致的,这里只陈述书写顺序的差异。
Fiber 类定义在 fiber.ts:184,state 字段在 :194,初值就是 FiberState.PENDING,状态变更会 emit internal/status。另外 :190 的 config 是已校验的配置,:192 的 _config 是原始配置、每次激活前重新解析。
PENDING 这个状态特别值得单独说。03-services.md:59 说 inject 列出的服务全部就位之前,Cordis 让插件停在 PENDING;:72 说删掉 provider 之后,consumer 会一直 PENDING、什么都不打印,不崩溃,而且「一个 PENDING 的 fiber 也不会让 Node 的事件循环保持存活」——所以在没有别的东西在跑的时候,整个组合会静默地 exit 0。06-composition-and-hmr.md:63 又强调一次:PENDING 是合法状态。写第一个插件时如果发现「什么都没发生也没报错」,先去看是不是卡在 PENDING,而不是先怀疑代码。
更细一层,03-services.md:76 说 inject 不是一次性的 boot 检查:运行中所需服务消失(provider 被卸载或热替换),每个依赖它的插件也会被卸载,并在服务回来时重新加载。
注册是可逆的,前提是走 Cordis 的手
docs/cordis-primer.md:13 的第五条是:注册是可逆的 effect,prompt 段落、工具 schema、适配器、provider、监听器都通过 ctx.effect() 或 ctx.on() 安装,重载与拆卸时可预期地回退。
02-lifecycle-and-effects.md:86-90 列了哪些东西本来就是 effect:ctx.on(event, listener)、ctx.plugin(child)、service 注册;并写明 harness 的注册表比如 ctx.tools.register(...) 也会把返回的 disposer 挂到调用它的插件上。04-events.md:78 顺着这条给了一句好记的结论:因为 ctx.on() 是 effect,监听器随插件消失,「永远不需要手写 removeListener 记账」。
不归 Cordis 管的资源,就得自己包进 ctx.effect(() => { ...; return () => {...} })。它的实现在 fiber.ts:415-418,label 参数默认值实读为 'anonymous'。函数体开头两步(fiber.ts:419-422)是先 assertActive(),再判断自身状态若为 FiberState.UNLOADING 就抛 CordisError('INACTIVE_EFFECT')。这个错误码表在 fiber.ts:171-173,实读只有一个码:INACTIVE_EFFECT: 'cannot create effect on inactive context'。vendor/README.md:38 记录的本地修改第 6 条也提到了这一处:effect 创建在 owner 处于 UNLOADING 时被拒绝,PENDING 与 LOADING 仍合法。
拆卸顺序有个 caveat 值得抄在便签上。02-lifecycle-and-effects.md:94 写:disposer 按注册的逆序开始,但多个异步 disposer 是并发跑的;如果你的拆卸步骤必须有序,就把它们放进同一个 disposer 里 await。fiber.ts:427-442 的实现用 disposables.splice(0).reverse() 逆序执行,重复调用直接返回同一个 disposalTask。另外 :66 说 fiber.dispose() 会在插件全部清理完成(含异步 disposer)后才 resolve,并递归卸载它挂载的子插件。
失败是响亮的,除非它不是
01-first-plugin.md:89 的原话是:apply 抛错会让进程死掉,「加载失败是响亮的失败,不是被跳过的条目」。配置校验失败也走 FAILED——05-config.md:64-68 给的非法配置报错样例是 ValidationError: invalid config: 加一行 - $.targets expected array but got not-an-array (at targets),此时插件的 fiber 进入 FAILED,教程用的那个启动器在打印错误后以状态码 1 退出。
但 01-first-plugin.md:91 紧接着补了一条例外:模块无法解析的条目(路径或包名拼错),是通过 Cordis logger service 报告的,而不是让进程崩溃;并且在 boot 阶段,这条报告可能在 console exporter 就位前就丢了。也就是说,「路径写错」和「代码写错」在这套体系里走的是两条完全不同的通道,前者不一定看得见。这一条在 docs/cordis-tutorial/01-first-plugin.md:91 被列为已知粗糙点。
配置本身还有一条硬约束容易被忽略:05-config.md:34 明写本仓用 Schemastery 做 schema(import Schema from '@deepseek-ai/schemastery'),而 Cordis 自身接受任意 Standard Schema 校验器,所以「导出一个普通对象当 Config 是不行的」。
从教程里的插件,到 harness 里的插件
上面这些都跑在教程的沙盘里。面向 harness 的最小插件写法在 docs/user/develop/basic/index.md:19-27,形状完全一样:导出 name 与 apply(ctx),:29 的原文是「That is the complete configuration.」。差别在挂载方式——:48-56 要求把插件通过 - insert: 补丁挂进 Web overlay,并且明写插件路径必须是绝对路径(The plugin path must be absolute.)。
再往上一层就是组合的叠加规则。docs/architecture.md:27 描述的顺序是:先按 profile 列出的顺序叠每个 bundle,然后是 profile 的 cordis.patch.yml,再是 home 级的那份,最后是 --patch overlay;一个补丁按 id 命中一行并替换其整个 config,而不是合并。packages/bundle/base/cordis.patch.yml 有 451 行,- id: 与 name: 各 78 处,01-first-plugin.md:51 就是把它指给读者看的,称之为「更长的插件组合」。
所以「最小插件」和「dsh 的正式插件」之间,并没有一层需要跨过去的抽象。差的只是挂载入口、绝对路径要求,以及你 inject 了谁——比如 07-into-the-harness.md:83 提到的那个坑:@deepseek-ai/dsh-tools 自己 inject 了 systemPrompt 服务,所以组合里必须一并列出它的 provider,否则 tools 插件会停在 PENDING。又是 PENDING。
延伸阅读
- 从头读起:DeepSeek Harness 是什么:建仓三天、13 万 star 的 Agent 框架
- 本专题共 45 篇,完整分组目录见专题页
- DeepSeek Harness 的「一切皆插件」:Cordis 到底承担了什么
- DeepSeek Harness 的服务注册与取用:inject 声明与 ctx 服务约定
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-16,对应仓库快照 47f9438(版本 0.1.0-rc.5)。本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,因此不涉及界面外观、操作手感与运行速度的任何描述。该仓库建立于 2026-08-13,README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。文中引用的启动命令会在本机执行代码,安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。