DeepSeek Harness 的服务注册与取用:inject 声明与 ctx 服务约定

2026-08-16

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.toolsctx.llmctx.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.tsservice.ts:11export 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 = ctxself.name = namedefineProperty(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.tsctx.provide(name, value)reflect.ts:44,返回一个注销该服务的 disposer,同一作用域重复提供、或者名字已被声明为 accessor,都会抛错;ctx.setreflect.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:787export class ToolRuntime extends Service
  • index.ts:788static inject = ['systemPrompt']
  • index.ts:827 — 构造函数第一行 super(ctx, 'tools'),服务名就是 tools,对应 ctx.tools
  • index.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。这里的 zindex.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:89apply 抛错会让进程死掉,「加载失败是响亮的失败」;05-config.md:64-68 里配置校验不过会打出 ValidationError: invalid config:,fiber 进入 FAILED,教程启动器打印错误后以状态码 1 退出。唯独依赖缺失是安静的。

怎么判定自己撞的是这个?按仓库文档给的路子,可执行的动作有三步:

  1. 看 fiber 状态。第 6 章 06-composition-and-hmr.md 列出的诊断 API 是 ctx.registry.values()runtime.fibersfiber.stateFiberState.PENDINGFiberState 定义在 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,就是依赖没齐。
  2. 比对 inject 数组与 cordis.yml 里的条目01-first-plugin.md:31 说条目是并发启动的,在文件里的位置不保证谁先加载,顺序来自服务依赖而非行序——所以「我明明写在前面」不是理由,缺的是 provider 条目本身。
  3. 注意诊断遍历本身的噪声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 占用了 toolsllm 这类朴素名字。想让两份配置不同的同名 provider 共存,办法是 isolate06-composition-and-hmr.md:21isolate 让一个 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 个,从 agentLoopapprovalcompaction 一路排到 tokenMetertoolsworkflowEngine;把测试目录也算进去则是 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:4Pick 列了六个成员:'interval' | 'timeout' | 'throttle' | 'debounce' | 'setTimeout' | 'setInterval'vendor/timer/src/index.ts:15ctx.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 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-16,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库建立于 2026-08-13,README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。 安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。