数据截至 (上游 commit 99f6f02fecdb)
第 1 章 · 底座:Cordis 插件树与「配置即产品」的分层组合
30 秒导读: DeepSeek Harness(下称 dsh)里没有一个「主程序」把各部件 new 出来。它的产品形态——是网页版还是一次性命令行、用哪个模型适配器、会话存哪儿——完全由一张插件行表决定,而这张表是几个 YAML 补丁文件层叠算出来的。本章讲清楚支撑这件事的框架:Cordis(vendor 里 pin 死的插件框架)和 dsh 在它之上的组装层。读懂本章,后面 02–06 章的「某某也是一个插件」才不会显得像魔法。
本章只讲底座。agent 主循环、会话日志、工具执行分别留给 第 3 章、第 2 章、第 4 章。
1. 先看结果:一个产品是怎么被「配」出来的
1.1 一句话直觉
把 dsh 想成一台接线盘:
- 每个能力(模型调用、会话存储、bash 工具、网页 UI……)是一块独立的插件板。
- 一份 YAML 行表说明「这次插哪些板、每块板拨到什么档位」。
- 换产品不是改代码,是换那份行表。
1.2 真实的一行长什么样
这是 base bundle 里几行原样的配置(packages/bundle/base/cordis.patch.yml:24-37):
- id: llm
name: '@deepseek-ai/dsh-llm'
- id: session
name: '@deepseek-ai/dsh-session'
- id: typert
name: '@deepseek-ai/dsh-typert-registry'
- id: typert-loader
name: '@deepseek-ai/dsh-typert-loader'
- id: typert-gateway
name: '@deepseek-ai/dsh-api-gateway'
一行三个字段就够了:id(这一行的稳定名字,后面所有补丁靠它定位)、name(要 import 的包)、可选的 config(传给这个插件的配置)。行的顺序不代表加载顺序——文件开头的注释明说了这点(packages/bundle/base/cordis.patch.yml:12-13),真正的启动次序由「谁的依赖先齐」决定 ,这是本章 §3 的内容。
1.3 「换一行就换实现」的三个真实例子
| 想换掉什么 | 改哪一行 | 效果 |
|---|---|---|
| 模型后端 | llm-deepseek 这一行的 name(base/cordis.patch.yml:450) | llm 这个服务定义不动,换掉的是它的适配器实现 |
| 会话落盘方式 | session-persistence-jsonl 行(base/cordis.patch.yml:98-101) | 换成别的 provider 包,会话日志改存别处 |
| 系统提示词人格 | 上层 bundle 用 - id: system-prompt 覆盖 config(headless/cordis.patch.yml:7-10、web-app/cordis.patch.yml:16-19) | 同一个插件,两个产品两套人格 |
再看一个更彻底的:整块能力按平台开关。
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
disabled: !!js process.platform === 'win32'
- id: pwsh-sandbox
name: '@deepseek-ai/dsh-pwsh-sandbox'
disabled: !!js process.platform !== 'win32'
(packages/bundle/base/cordis.patch.yml:178-186。!!js 是一个自定义 YAML 标量标签,值是一段延迟求值的 JS 表达式,§5.4 讲它什么时候被求值。)
1.4 记住这条主链
dsh --profile web "帮我改代码"
│
├─(1) 解析启动参数 apps/cli/src/args.ts
│ 只认自己的 --profile / --patch,其余原样交给被启动的 app
│
├─(2) 叠补丁 apps/cli/src/profile-boot.ts
│ bundle 层 → profile 层 → home 层 → --patch 覆盖层
│
├─(3) 起根 Context packages/boot/app-boot/src/index.ts boot()
│ new Context() → 装 Loader → 挂 cordis:include
│
└─(4) 逐行 import + 装载 vendor/loader、vendor/cordis
每一行 = 一次 ctx.plugin(插件, 行的 config) = 一个 Fiber
下面先把 (4) 里的 Cordis 讲透(§2–§4),再回头讲 (1)(2)(3) 的组装层(§5)。
2. Cordis 的三个名词:Context / Service / Fiber
Cordis 是 dsh **源码内嵌(vendored)**的插件框架,源码就在 vendor/cordis/src/ 下,锁死在一个上游提交上。它的整个心智模型只有三个词。
2.1 三个词的分工
| 名词 | 白话 | 你在代码里怎么见到它 | 源码 |
|---|---|---|---|
Context | 一个「能拿到别人、也能被别人拿到」的作用域对象 | 插件函数的第一个参数 ctx | vendor/cordis/src/context.ts:42 |
Service | 挂在 ctx 上的一个具名能力(ctx.llm、ctx.agents) | class X extends Service | vendor/cordis/src/service.ts:11 |
Fiber | 「这个插件这一次被装载」的运行时实例 | ctx.fiber,ctx.plugin() 的返回值 | vendor/cordis/src/fiber.ts:184 |
一句话串起来:一次 ctx.plugin(P, config) 产生一个 Fiber,Fiber 给插件一个子 Context,插件在这个 Context 上注册若干 Service 和监听器。
2.2 一个插件长什么样
// 示意,非源码
export const name = 'my-plugin'
export const inject = ['llm'] // 声明:没有 llm 服务我就不启动
export function apply(ctx, config) {
ctx.llm.registerAdapter(['my-provider'], myAdapter) // 用别人的服务
ctx.on('some/event', handler) // 挂监听器
}
重点看两处:inject 是声明而不是查找——框架保证 apply 跑起来时 ctx.llm 一定在;ctx.on 注册的东西不用你手动清理(§4)。
3. Context:一个会自己解析依赖的代理对象
3.1 它解决的小问题
依赖注入通常要么用容器(container.resolve('llm'),字符串满天飞),要么用构造函数参数(层层透传)。Cordis 选了第三条:让 ctx.llm 这个普通属性读取本身就是一次依赖解析。
3.2 实现:构造函数返回的是 Proxy
Context 的构造函数里有一行反直觉的代码——它 return 了一个代理,而不是 this:
const self = new Proxy<this>(this, ReflectService.handler)
(vendor/cordis/src/context.ts:74,末尾 return self。)所有属性读写都会落到 ReflectService.handler(vendor/cordis/src/reflect.ts:135)。
get 陷阱的逻辑,按顺序是:
- 是自有属性 → 直接返回;
- 是 accessor(
ctx.accessor()声明的计算属性)→ 走它的get; - 否则派发
internal/getwaterfall,默认行为是沿 Fiber 链向上找:fiber.store?.[prop]命中就返回,否则看父 Fiber,直到跨出隔离域或走到根就抛错(vendor/cordis/src/reflect.ts:152-167)。
关键在第 3 步找的是 fiber.store ——只有本 Fiber 声明过 inject 的服务才在这个 store 里(store 由 Fiber._checkImpl 填,vendor/cordis/src/fiber.ts:597-609)。所以没声明就读,报的错是 cannot get property "x" without inject。这不是防呆,是整套依赖模型的基石:谁用谁声明,框架才知道该在什么时候把谁重启。
3.3 三种造子 Context 的方式
三个方法都不改父 Context,都返回一个新的子对象。
| 方法 | 干什么 | 典型用途 | 源码 |
|---|---|---|---|
extend(meta) | 原型继承出一个子 ctx,用 meta 的自有属性遮蔽继承来的 | 框架内部给每个 Fiber 造它的 ctx(fiber.ts:236) | vendor/cordis/src/context.ts:99-107 |
isolate(name, label?) | 让 name 这个服务在子树里解析到另一个隔离域 | 一个 preset 想独占一份 planMode | context.ts:121-125 |
intercept(name, config) | 给子树里的插件注入「读某服务时额外的配置」 | 按调用方定制服务行为 | context.ts:158-166 |
isolate 的实现只有三行,本质是在 symbols.isolate 这张「服务名 → 域标签」的映射上再叠一层原型:
const shadow = Object.create(this[symbols.isolate])
shadow[name] = label ?? Symbol(name)
return this.extend({ [symbols.isolate]: shadow })
服务注册和查找都用这个标签做 key(reflect.ts:286-294 的 provide、reflect.ts:238 的 _getImpl),所以「换一个标签」就等于「换一个平行宇宙里的同名服务」。
配置文件里也能直接用它。真实的 agent preset 就是这么给「计划模式」开独立域的(apps/cli/config/agent-presets/standard/agent.cordis.yml:104-108):
- id: planning
name: cordis:group
group: true
isolate:
planMode: true
intercept 的配置怎么合并,写在 Service[symbols.resolveConfig] 里:沿 symbols.intercept 的原型链往上走,越靠近根的越先应用(vendor/cordis/src/service.ts:86-102)。
3.4 ctx.<name> 与 ctx.get(name) 的分工(全库约定)
这是贯穿整个 dsh 代码库的一条硬规矩:
ctx.<name>:只用于自己声明过inject的服务。 它走上面那条 Fiber 链查找,是拓扑敏感的——同名服务在不同隔离域里可能是不同实例。ctx.get(name):用于可选服务。 它绕过 inject 要求,直接按隔离标签读全局服务表,拿不到就返回undefined(vendor/cordis/src/reflect.ts:233-242)。
真实用法:网关要枚举「当前所有活着的服务」,逐个 this.ctx.get(serviceKey), 拿不到就跳过(packages/api/gateway/src/index.ts:126);启动器要判断「这套组合有没有挂 HMR」,写 ctx.get('hmr') === undefined(apps/cli/src/profile-boot.ts:279-283)。两处都是「有就用,没有就走别的路」,正是 ctx.get 的语义。
4. Service 与 Fiber:声明依赖,然后被自动上下线
4.1 Service:构造即注册
Service 基类的构造函数只做一件事——把自己登记到当前 Context:
self.ctx.reflect.provide(name, self, this[symbols.check])
(vendor/cordis/src/service.ts:57。)所以服务类的写法就是「super(ctx, '名字') 一行完事」:
export class LlmRuntime extends Service {
constructor(ctx: Context) {
super(ctx, 'llm')
}
(packages/llm/llm/src/index.ts:284,292-294。)
依赖用静态字段声明。网关声明它需要 typert 注册表:
export class TypertGatewayService extends Service implements TypertGateway {
static inject = ['typert']
(packages/api/gateway/src/index.ts:90-91。同样的写法散布全库,例如 packages/session/session-persistence-jsonl/src/index.ts:124 的 static inject = ['sessions']。)
inject 支持数组和对象两种形态,对象形态的值就是给那个服务的 intercept 配置;Inject.resolve 把两种形态和类继承来的声明统一成一张平表(vendor/cordis/src/registry.ts:71-88)。
4.2 Fiber:一次装载的运行时实例
ctx.plugin(P, config) 干的事很短:解析出插件的可执行体、复用或新建 Plugin.Runtime 记录、然后 new Fiber(...)(vendor/cordis/src/registry.ts:316-336)。同一个插件被装载 N 次就有 N 个 Fiber,共享一个 Runtime。
Fiber 有六种状态(vendor/cordis/src/fiber.ts:147-154):
| 状态 | 含义 |
|---|---|
PENDING | 依赖没齐,等着 |
LOADING | 正在跑插件主体 |
ACTIVE | 已加载,服务对外可见 |
FAILED | 插件主体或配置校验抛了错 |
UNLOADING | 正在跑 disposer |
DISPOSED | 已移除,不会再起来 |
4.3 「依赖齐了没」是怎么算出来的:epoch 字符串
这是 Cordis 最巧的一处。Fiber 不用布尔值记「依赖是否满足」,而是把依赖状态编码成一个字符串(vendor/cordis/src/fiber.ts:611-623,_refresh):
for (const name of Object.keys(this.inject)) {
const impl = this._store[name]
if (!impl) { epoch = INACTIVE; break }
epoch += ':' + impl.fiber.uid
}
this._setEpoch(epoch)
于是:
- 少任何一个依赖 → epoch 是
INACTIVE; - 依赖都在 → epoch 是各提供者 Fiber uid 拼出来的指纹,比如
:3:7。
_setEpoch(fiber.ts:625-639)只比较新旧字符串:
- 从
INACTIVE变成别的 →_reload(),起来; - 其他任何变化(包括提供者换了个实例导致指纹从
:3:7变成:9:7)→_unload(),卸掉;卸完发现新 epoch 又有效,_unload结尾会自己再调_reload(fiber.ts:688-695)。
这就是热插拔的全部机制。用 ASCII 看这个环:
依赖变化(reflect.notify)
│
▼
_checkImpl → _refresh → 算出新 epoch
│
├─ 旧 INACTIVE,新有效 ──▶ LOADING ──跑 apply()──▶ ACTIVE
│ └──抛错──▶ FAILED
│
└─ 其他变化 ──▶ UNLOADING ──跑完所有 disposer──▶
│
新 epoch 还有效? ──是──▶ 回到 LOADING
└──否──▶ PENDING
「 谁触发依赖变化」也很明确:任何服务的注册/注销都会调 ReflectService.notify,它遍历所有 Fiber,凡是 inject 里有这个名字的就重算一遍(vendor/cordis/src/reflect.ts:314-330)。
4.4 ctx.effect():注册即可逆副作用
问题: 插件运行时会往外面挂东西——监听器、注册表条目、定时器、打开的文件。插件被卸载时,这些必须一件不落地撤掉,否则热重载一次就泄漏一次。
Cordis 的答案: 不给你「注册」和「注销」两个 API,只给你一个 ctx.effect(执行体),执行体返回它自己的清理函数。
// 示意,非源码
ctx.effect(() => {
const handle = registry.add(entry) // 副作用
return () => registry.remove(handle) // 它的逆操作,就地写清楚
}, 'my.register()')
框架收集这些清理函数,在 Fiber 卸载时逆序执行(vendor/cordis/src/fiber.ts:418-561 的 effect;_unload 在 fiber.ts:675-696 清空 _disposables)。执行体也可以是生成器或 async 生成器,yield 出的每个清理函数都会被逐个收集(fiber.ts:375-395)。
dsh 里的真实用法——LLM 服务注册适配器:
const dispose = this.ctx.effect(function* (this: LlmRuntime) {
if (providers.length === 0) throw new LlmError(...)
this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned))
yield () => {
released = true
for (const provider of owned) this.adapters.delete(provider)
owned.clear()
this.emitAdaptersUpdated()
}
}.bind(this), 'llm.registerAdapter()')
(packages/llm/llm/src/index.ts:365-383,registerAdapter。)注意最后那个字符串 'llm.registerAdapter()' 是 effect 标签——fiber.getEffects() 会把它们组织成一棵树用于诊断(fiber.ts:568-572)。
框架自己也全部走这条路:
ctx.on(...)内部是EventsService.register→this.ctx.fiber.effect(...),标签形如ctx.on("llm/stream")(vendor/cordis/src/events.ts:254-260);ctx.provide(...)也是一个 effect,标签ctx.provide("llm")(vendor/cordis/src/reflect.ts:277-304);- 连「子插件」本身都是父 Fiber 上的一个 effect,标签
'ctx.plugin()'(vendor/cordis/src/fiber.ts:265-297)。
所以整棵插件树是一棵 effect 树:卸掉任何一层,它下面的所有注册(包括子插件)都会按逆序退干净。dsh 的仓库规约把这条写成硬性要求——每个注册表的 register() 都必须返回 disposer,并用「卸载 Fiber 后观察条目消失」的测试来证明。
5. 事件:五种派发模式,和 waterfall 的 next() 陷阱
5.1 五种模式一览
ctx 上直接混入了五个 派发方法(混入发生在 vendor/cordis/src/reflect.ts:222)。语义定义见 vendor/cordis/src/events.ts:32(DispatchMode)与各方法实现:
| 方法 | 同步/异步 | 停不停 | 返回什么 | 实现 |
|---|---|---|---|---|
emit | 同步,不等 Promise | 全跑 | 无 | events.ts:194-196 |
parallel | 并发,allSettled 后聚合错误 | 全跑 | Promise<void> | events.ts:183-187 |
serial | 依次 await | 遇到 bail 值就停 | 第一个 bail 值 | events.ts:204-209 |
bail | 同步依次调 | 遇到 bail 值就停 | 第一个 bail 值 | events.ts:217-222 |
waterfall | 由监听器自己决定 | 谁不调 next() 谁就终止 | 最外层监听器的返回值 | events.ts:234-243 |
「bail 值」的判定很宽松:只要不是 null/false/undefined 就算数(events.ts:13-15,isBailed)。
5.2 派发前的两次筛选
EventsService.dispatch(events.ts:165-175)在真正调监听器之前做两件事:
- 如果第一个参数是对象/函数,就把它当作
thisArg取走——这既是监听器的this,也是过滤器的来源; - 按
hook.global || !filter || filter.call(thisArg, hook.ctx)过滤监听器。
Service 提供的默认过滤器是「隔离域必须一致」(vendor/cordis/src/service.ts:61-63)。所以像 this.ctx.waterfall(this, 'llm/stream', ...) 这样把服务实例当 thisArg 传进去(packages/llm/llm/src/index.ts:989-994),效果是:只有和这个 llm 实例处在同一隔离域的监听器会收到事件。多 agent 各自隔离时,这一条决定了事件不会串台。
5.3 waterfall:不调 next() 就等于否决
waterfall 的实现只有 9 行,值得逐行看(vendor/cordis/src/events.ts:234-243):
waterfall(...args: any[]) {
const cbs = this.dispatch('waterfall', args)
const inner = args.pop() // 最后一个参数是「内置行为」
const next = () => {
const cb = cbs.shift() ?? inner // 取下一个监听器,取完了就是内置行为
return cb(...args)
}
args.push(next) // 每个监听器拿到的最后一个参数就是 next
return next()
}
读法:这是一条洋葱链。每个监听器包在下一层外面,它拿到的最后一个参数是 next。
ctx.waterfall(this, 'llm/stream', options, () => 真正去调模型)
[校验监听器] ──next()──▶ [路由监听器] ──next()──▶ [内置行为:调 adapter]
│ │
└── 不调 next() ─────────┴──▶ 链条到此为止,内置行为根本不会执行
这就是全库反复强调的那条规矩:waterfall 监听器必须调用 next() 才能向下委托;直接 return 会短路整条链,包括框架的内置行为。 这不是风格建议——从上面的实现能直接看出,cbs.shift() 只在 next() 里发生,不调用就没有下一棒。
真实的「包一层」写法,LLM 的运行时不变量校验器:
ctx.on('llm/stream', (_options, next) => validateStream(next(), fail), { global: true, prepend: true })
(packages/llm/llm/src/invariant.ts:88。prepend: true 让它排在最外层,global: true 让它跳过隔离域过滤。)它调了 next() 拿到下游的 chunk 流,再包一层校验后返回——典型的中间件。
框架自己也靠 waterfall 开放了几个关键接缝(vendor/cordis/src/events.ts:329-352 的 Events 接口):internal/config(解析插件配置)、internal/update(配置热更新,不调 next() 就是否决重启)、internal/get / internal/set(服务读写)。