DeepSeek Harness 的服务注册与取用:inject 声明与 ctx 服务约定
翻 deepseek-harness 的源码时,最先让人卡住的往往不是某个算法,而是一个很朴素的问题:一个插件里写 ctx.tools.register(...),这个 ctx.tools 是从哪儿来的?仓库里搜不到哪一行把它 import 进来,也找不到一个全局单例。答案在它 vendored 的插件框架 Cordis 里——服务不是被 import 的,是被认领和声明依赖的。
先把限定条件说在前面:这个仓库建立于 2026-08-13,我们采集是 2026-08-16,前后只差三天;根 package.json 的版本是 0.1.0-rc.5,GitHub 上一个 Release 都没有;README.md:9-11 有独立的 Developer preview 小节,用全大写写着 THERE WILL BE COMPATIBILITY-BREAKING CHANGES.。下面提到的每一个 API 名字、默认值、行号都可能随时变。
context 是服务的仓库,不是全局对象
docs/cordis-primer.md:10 把这件事概括成一句:context 是「服务的仓库(a repository of services)」,一个 service 认领一个稳定的 ctx.<key>,例如 ctx.tools、ctx.llm、ctx.sessions;别的插件按 key 找服务,而不是 import 具体实现。docs/cordis-api/context.md:10 补充了实现层面的说法:context 是一个 proxy,普通属性读取会走 service resolver;extend()、isolate()、intercept() 创建作用域化的子 context,而不会改动父 context。
所以 ctx.tools 不是一个字段,是一次解析。这也解释了为什么同一句 ctx.shell 在不同插件里可能拿到不同实例——后面讲 isolate 时会回到这一点。
注册端:super(ctx, name) 那一行做了什么
服务的注册入口在 vendor/cordis/src/service.ts。service.ts:11 是 export abstract class Service<out T = never>,构造函数 constructor(protected ctx: Context, name: string) 在 service.ts:42。它的 JSDoc(service.ts:32-41)写明构造时会调用 ctx.reflect.provide(name, this, this[Service.check]);service.ts:53-57 的实读顺序是 self.ctx = ctx、self.name = name、defineProperty(self, symbols.tracker, tracker),最后一行才是 provide。
这里有一条对写插件的人很重要的结论,也写在同一段 JSDoc 里:服务会在所属 fiber 卸载时自动注销。你不需要在 dispose 里手写「把自己从注册表摘掉」。tutorial 第 2 章 02-lifecycle-and-effects.md:86-90 把这类东西统称为 effect——ctx.on(event, listener)、ctx.plugin(child)、service 注册,本来就是 effect;harness 自己的注册表如 ctx.tools.register(...) 返回的 disposer 也会被挂到调用它的那个插件上。
底层的读写 API 在 vendor/cordis/src/reflect.ts:ctx.provide(name, value) 在 reflect.ts:44,返回一个注销该服务的 disposer,同一作用域重复提供、或者名字已被声明为 accessor,都会抛错;ctx.set 在 reflect.ts:29,只有提供该服务的 fiber 能 set,对未提供的名字 set 会抛。
至于类型,第 3 章 03-services.md:39-40 说得很直白:运行时靠 super(ctx, 'greeter') 注册,编译期靠 declare module '@deepseek-ai/cordis' { interface Context { ... } } 的声明合并。后者不生成任何代码,缺了它服务在运行时照样能用,只是消费方失去类型安全。
declare module '@deepseek-ai/cordis' {
interface Context {
greeter: Greeter
}
}
取用端:inject 不是启动检查,是一段持续的约定
消费方的写法只有一行:export const inject = ['greeter']。语义在 03-services.md:59——在 inject 列出的服务全部存在之前,Cordis 让这个插件停在 PENDING。
inject 的两种形态定义在 vendor/cordis/src/registry.ts:19:数组形态请求服务而不带 intercept 配置;对象形态把服务名映射到可选的 intercept 配置。它也是 Plugin.Base 的共享元数据字段之一(registry.ts:92 那块类型里,还有 name?、Config?、provide?、intercept?)。想在代码里临时依赖一段服务,可以用 ctx.inject(deps, callback)(registry.ts:176),文档说它是 ctx.plugin({ inject, apply: callback }) 的简写,任一所需服务变化时该回调会被卸载并重跑。
真正容易被低估的是 03-services.md:76 那句:inject 不是一次性 boot 检查。运行中所需服务消失(provider 被卸载或热替换),每个依赖它的插件也会被卸载,并在服务回来时重新加载。03-services.md:78 给的例子就是 shell:卸掉 dsh-bash-local 条目、挂另一个 shell provider,所有 inject 'shell' 的插件会干净地对新实现重启。顺带说明一句,文档这个例子里的 shell provider 意味着 dsh 会在本机执行外部程序,替换 provider 就是在替换「谁来跑这些命令」,我们没有运行过其中任何一条。
如果依赖是可选的,03-services.md:87 给了另一条路:不写 inject,在使用点调 ctx.get('greeter') 探测,没有 provider 时返回 undefined。这个方法的签名在 reflect.ts:17,文档写 strict 默认 true,只返回其提供者 fiber 当前处于 active 的实现。
harness 里的真实一例:ToolRuntime
把上面两端接起来看 packages/core/tools/src/index.ts:
index.ts:787—export class ToolRuntime extends Serviceindex.ts:788—static inject = ['systemPrompt']index.ts:827— 构造函数第一行super(ctx, 'tools'),服务名就是tools,对应ctx.toolsindex.ts:832— 构造时即调用ctx.systemPrompt.tools(...)向系统提示词接线
也就是说,ctx.tools 这个能力本身也是被 inject 出来的:它先要有 systemPrompt。tutorial 第 7 章 07-into-the-harness.md:83 明确写了这个后果——@deepseek-ai/dsh-tools 注入 systemPrompt 服务(工具要向系统提示词贡献 schema),所以组合文件里必须一并列出它的 provider,否则 tools 插件会停在 PENDING。
顺便记下这个类的两个配置默认值(index.ts:790-793):mode 默认 'native'(可选 native/code/both),maxParallelSubCalls 默认 10。这里的 z 是 index.ts:8 导入的 @deepseek-ai/schemastery,不是 zod。这两个是配置里的默认值,不代表任何实际表现,别拿它推算并发能力。
依赖没满足时,它不报错——这是最容易踩的坑
03-services.md:72 写得很清楚:删掉 provider 之后,consumer 会一直 PENDING、什么都不打印,不崩溃;而且「一个 PENDING 的 fiber 也不会让 Node 的事件循环保持存活」,所以在没有别的东西在跑时,整个组合会静默地 exit 0。第 6 章 06-composition-and-hmr.md:63 又强调了一遍:inject 了没人提供的服务的插件会永远等待,PENDING 是合法状态。
这跟另一类失败形成对照。01-first-plugin.md:89 说 apply 抛错会让进程死掉,「加载失败是响亮的失败」;05-config.md:64-68 里配置校验不过会打出 ValidationError: invalid config:,fiber 进入 FAILED,教程启动器打印错误后以状态码 1 退出。唯独依赖缺失是安静的。
怎么判定自己撞的是这个?按仓库文档给的路子,可执行的动作有三步:
- 看 fiber 状态。第 6 章
06-composition-and-hmr.md列出的诊断 API 是ctx.registry.values()、runtime.fibers、fiber.state与FiberState.PENDING。FiberState定义在vendor/cordis/src/fiber.ts:147-154,成员书写顺序实读为PENDING, LOADING, ACTIVE, FAILED, DISPOSED, UNLOADING;第 2 章02-lifecycle-and-effects.md:73的状态机图写作PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED,另有一条↘ FAILED。停在 PENDING,就是依赖没齐。 - 比对
inject数组与cordis.yml里的条目。01-first-plugin.md:31说条目是并发启动的,在文件里的位置不保证谁先加载,顺序来自服务依赖而非行序——所以「我明明写在前面」不是理由,缺的是 provider 条目本身。 - 注意诊断遍历本身的噪声。
06-composition-and-hmr.md:109提醒,不加 PENDING 过滤地遍历,还会看到 loader 自己的插件(Loader、Include)处于 ACTIVE。
一个现成的例子在 vendored 插件里:vendor/hmr/src/index.ts:87 写着 static inject = ['loader', 'timer'],06-composition-and-hmr.md:42 据此说明,HMR 用 timer 服务做防抖,没有 @deepseek-ai/cordis-plugin-timer 它会永远静默地停在 PENDING;而且它的日志走 Cordis logger service,没有 console exporter 就更看不到动静。
什么情况说明不是这个原因?如果进程直接崩了、或者打出了 ValidationError 并以 1 退出,那是 apply 抛错或配置校验失败,跟 inject 无关;如果日志里报的是模块解析失败(路径或包名拼错),01-first-plugin.md:91 说这类问题是通过 Cordis logger service 报告的,不会让进程崩溃,并且在 boot 阶段这条报告可能在 console exporter 就位前就丢了——那是条目本身没被解析,不是依赖在等。
名字是扁平的,只有 isolate 能开分身
03-services.md:94 写明:service 名字在一个应用内是一个扁平的命名空间,harness 占用了 tools、llm 这类朴素名字。想让两份配置不同的同名 provider 共存,办法是 isolate:06-composition-and-hmr.md:21 说 isolate 让一个 group 拥有某个 service 名字的独立实例,两个 group 各自看到不同配置的 shell provider 而互不影响;对应的 API 是 ctx.isolate(name, label?)(vendor/cordis/src/context.ts:121),文档说明在返回的 context 之下,该名字的读写解析到新 label,两次 isolate() 传同一个 label 会把两个作用域合并。
这个扁平命名空间到底有多满?我们用正则从 packages/ 的源码里数了一遍(排除 tests/ 与 *.spec.ts/*.test.ts/*.e2e.ts):class X extends Service 有 66 处,由 super(ctx, '<name>') 得到的不重复服务名 67 个,从 agentLoop、approval、compaction 一路排到 tokenMeter、tools、workflowEngine;把测试目录也算进去则是 96 处、84 个名字,多出来的是测试夹具。截至 2026-08-16 快照 47f9438。需要说明的是,这是我们自己数的,不是仓库的官方口径——docs/subsystems/*.md 里有生成的 cordis-surface 区块,我们没有去查,所以拿不出「官方一共登记了多少个可 inject 的服务」这个数字。
一处文档与源码对不上的地方
服务除了整块挂在 ctx.<key> 上,还可以把若干方法 mixin 到 ctx 上(ctx.mixin(name, mixins),reflect.ts:67)。timer 就是这么干的,而这里两处写法不一致:
docs/cordis-api/inherited.md:19原文写ctx.timer (+ interval / timeout / throttle / debounce),并括注「the four supported helpers are mixed onto ctx directly (declared via Pick)」,是四个。- 源码
vendor/timer/src/index.ts:4的Pick列了六个成员:'interval' | 'timeout' | 'throttle' | 'debounce' | 'setTimeout' | 'setInterval';vendor/timer/src/index.ts:15的ctx.mixin('timer', [...])也是同样六个。其中setTimeout(:18-21)与setInterval(:23-26)在源码里带@deprecated标注。
两处不一致,以我们实读的源码为准。不推断原因,也不由此评价什么。
一句话收束
写 dsh 插件时,与其去找「那个类在哪」,不如先回答两个问题:我要的能力叫什么名字(ctx.<key>),谁在提供它(哪一条 cordis.yml 条目)。注册端只有 super(ctx, name) 一行是必须的,声明合并纯为类型;取用端只有 inject 一行是必须的,ctx.get() 留给可选依赖。剩下的就是记住那条安静的失败路径:什么都没打印、进程还 exit 0,八成是有人停在 PENDING。
以上关于服务注册与取用的写法均照抄仓库文档与源码中的片段,未经实测,以官方文档与 --help 的实际输出为准。
延伸阅读
- 从头读起:DeepSeek Harness 是什么:建仓三天、13 万 star 的 Agent 框架
- 本专题共 45 篇,完整分组目录见专题页
- DeepSeek Harness 最小插件怎么写:apply、ctx 与插件生命周期
- DeepSeek Harness 的 Cordis 事件派发:文档四种、源码是五种
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-16,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库建立于 2026-08-13,README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。