Cordis 底座:Context、Service、Fiber、Effect
2693 行里的四个原语,以及五种事件派发的确切语义
本章解析
vendor/cordis/src/全部 2693 行。这是理解 dsh 的地基——后面 10 章讲的每一个"扩展点",本质上都是这 2693 行提供的四个原语的组合。
0. 先说结论
Cordis 是 dsh vendored 进来的插件框架(vendor/cordis/,源码 9 个文件、2693 行)。它把三样通常分开实现的东西焊在一起:
| 通常的做法 | Cordis 的做法 | 焊在一起的收益 |
|---|---|---|
| DI 容器(服务注册 + 解析) | Context 是一个 Proxy,服务就是 ctx.<name> |
依赖不靠 import,靠名字 |
| 事件总线(EventEmitter) | ctx.on / emit / waterfall / ... 直接挂在同一个 ctx 上 |
监听器天然带"谁注册的"这个身份 |
| 生命周期(启动顺序、优雅关闭) | Fiber 状态机 + ctx.effect() |
注册即可撤销,卸载一个插件不会漏资源 |
第三行是关键。绝大多数插件框架能做到"加载插件",做不到"干净地卸载插件"——因为插件在加载过程中往全局注册表里塞了东西(路由、监听器、定时器、子进程),没人记得清都塞了哪些。Cordis 的答案是:所有注册都必须经过 ctx.effect() 或 ctx.on(),框架替你记账。
这条约束在 dsh 的 AGENTS.md 里被抬成了硬性规范:
Registrations are effects: every contribution goes through
ctx.effect()/ctx.on(); a registry'sregister()returns the disposer.
于是 dsh 才敢做那些别的 harness 不敢做的事——比如第 12 章会讲的:让模型在运行中给自己装插件。
本章按依赖顺序讲四个原语:
Context ── 服务住在哪里(proxy + 三种派生)
│
Service ── 怎么成为一个服务(构造即注册)
│
Fiber ── 谁在管生命周期(六态状态机 + epoch)
│
Effect ── 怎么保证撤得干净(记账 + 反序回滚)
│
Events ── 怎么互相通信(五种 dispatch + 过滤)
1. Context:一个会"说谎"的对象
1.1 它是个 Proxy
先看构造函数(vendor/cordis/src/context.ts:71):
constructor() {
this[symbols.isolate] = Object.create(null)
this[symbols.intercept] = Object.create(null)
const self = new Proxy<this>(this, ReflectService.handler) // ← 注意这里
this.root = self
this.baseUrl = undefined
this.fiber = new Fiber(self, {}, Object.create(null), null, () => [])
this.reflect = new ReflectService(self)
this.registry = new RegistryService(self)
this.events = new EventsService(self)
this.logger = new LoggerService(self)
this.fiber._disposables.clear()
return self // ← 构造函数返回 proxy,不是 this
}
constructor 返回了 Proxy 而不是 this。这意味着你拿到的每一个 ctx 都是代理对象,ctx.tools 这样的属性读取会被 ReflectService.handler 截获,走服务解析逻辑,而不是普通的属性查找。
这解释了 dsh 一条容易踩坑的规范(packages/AGENTS.md):
Optional services use
ctx.get(name). Reservectx.<name>for declared injections; the property proxy is topology-sensitive, while strictctx.getreads the global service store.
ctx.tools 是"拓扑敏感"的——它的结果取决于你这个 ctx 在上下文树里的位置(isolate 标签、你声明了哪些 inject)。而 ctx.get('codeRuntime') 绕过拓扑,直接读全局服务表。所以:
- 声明了
static inject = ['tools']→ 用ctx.tools; - 可选依赖(有就用、没有就降级)→ 用
ctx.get('codeRuntime')。
dsh 里 dsh-tools 读 code runtime 就是后者,并且注释写清楚了为什么不能用 inject(packages/core/tools/src/index.ts:1019):
private requireCodeRuntime(mode: ToolPresentationMode): CodeRuntime {
const runtime = this.ctx.get('codeRuntime')
if (!runtime) {
throw new Error(`dsh-tools: mode "${mode}" requires a code runtime — ...`)
}
因为如果写成 inject,那么 ctx.tools ——以及排在它后面的每一个工具插件——都会被"必须存在一个 code runtime"绑为人质,即使部署根本跑在 mode: 'native'。这是一个非常具体的、可迁移的判断标准:inject 是"没有就不启动",不是"没有就少个功能"。
1.2 三种派生:extend / isolate / intercept
Context 不可变——你不能改一个 ctx,只能派生一个新的。三种派生方式,语义完全不同:
extend(meta) —— 加元数据(context.ts:99):
extend(meta = {}): this {
const shadow = Reflect.getOwnPropertyDescriptor(this, symbols.shadow)?.value
const self = Object.create(getTraceable(this, this)) // 原型继承
for (const prop of Reflect.ownKeys(meta)) {
Object.defineProperty(self, prop, Reflect.getOwnPropertyDescriptor(meta, prop)!)
}
...
}
Object.create(parent) —— 子 ctx 原型继承父 ctx,meta 里的自有属性遮蔽继承来的。父 ctx 不被修改。
dsh 用它给 ctx 打身份标签。第 4 章会讲的 ReactLoopAgent 构造函数里:
// packages/core/agent-loop/src/agent.ts
this.scope = createScope(loopCtx, this)
this.ctx = this.scope.ctx.extend({ agent: this })
这样 agent.ctx 上任何插件都能读到 ctx.agent 拿到"我服务的是哪个 agent"。而 createScope 内部(packages/core/scope/src/index.ts:140)也是 extend:
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
isolate(name, label) —— 换服务的解析域(context.ts:121):
isolate(name: string, label?: symbol) {
const shadow = Object.create(this[symbols.isolate])
shadow[name] = label ?? Symbol(name)
return this.extend({ [symbols.isolate]: shadow })
}
在返回的 ctx 之下,对服务 name 的读写落在新标签的域里。同一个 label 传给两次 isolate() 会让它们共享同一个域。用途:给一部分插件换一套 ctx.fs,而不影响外面。
intercept(name, config) —— 给下游插件注入服务配置(context.ts:139):
在返回的 ctx 之下加载的插件,看到的该服务配置被 merge 了一层。合并顺序由 Service[symbols.resolveConfig] 决定(service.ts:86):
[symbols.resolveConfig](base?: T, head?: T): T {
let intercept = this.ctx[Context.intercept]
const configs: any[] = []
while (this.name in intercept) {
if (Object.hasOwn(intercept, this.name)) {
configs.unshift(intercept[this.name]) // ← unshift:越靠根越先
}
intercept = Object.getPrototypeOf(intercept) // ← 沿原型链上爬
}
if (base) configs.unshift(base)
if (head) configs.push(head)
...
}
注意这段代码怎么利用原型链:intercept map 本身是原型链(每次 intercept() 都 Object.create(parent)),所以"沿上下文树往上找所有 intercept 配置"退化成"沿原型链走",一个 while 循环搞定。离根越近的先应用,离用的地方越近的后应用(优先级更高)——这正是配置覆盖该有的方向。
2. Service:构造即注册
Service 基类只有 115 行(vendor/cordis/src/service.ts),核心是构造函数(service.ts:42):
constructor(protected ctx: Context, name: string) {
name ??= this.constructor['provide'] as string
let self = this
const tracker: Tracker = { associate: name, property: 'ctx' }
if (self[symbols.invoke]) {
self = createCallable(name, joinPrototype(Object.getPrototypeOf(this), Function.prototype), tracker)
}
self.ctx = ctx
self.name = name
defineProperty(self, symbols.tracker, tracker)
self.ctx.reflect.provide(name, self, this[symbols.check]) // ← 就是这一行
return self
}
四个值得记住的点:
super(ctx, name)一执行,服务就已经注册好了。不需要额外调用register()。dsh 里几乎所有 Service 子类的构造函数第一行就是super(ctx, 'sessions')之类。provide走的是reflect.provide(),而provide内部是一个 effect —— 所以拥有它的 fiber 卸载时,服务自动摘除。你不需要写"卸载时把自己从 ctx 上删掉"的代码。[symbols.invoke]让服务变成可调用对象。这就是为什么ctx.logger既是对象又能ctx.logger('name')调用。[symbols.check]是可用性谓词——服务可以声明"我现在还不算就绪",依赖方会一直等。
2.1 filter:服务只把事件发给"同域"的监听器
service.ts:61 这三行是本章后半程的伏笔:
protected [symbols.filter](ctx: Context) {
return ctx[symbols.isolate][this.name] === this.ctx[symbols.isolate][this.name]
}
服务派发事件时,默认只把事件交给和自己处于同一个 isolate 域的监听器。你 isolate 出去的那套 ctx.fs,它的事件不会漏到外面。第 7 节会看到 dsh 怎么把这三行"魔改"成整个 scoped dispatch 体系。
3. inject:不是 import,是"等到"
3.1 声明形式
三种写法都归一成同一个 map(vendor/cordis/src/registry.ts:71):
export function resolve(inject: Inject | null | undefined, result: Dict = Object.create(null)) {
if (!inject) return result
if (Array.isArray(inject)) {
for (const name of inject) result[name] = null // ['tools', 'llm']
} else if (Reflect.has(inject, symbols.checkProto)) {
Object.assign(result, resolve(Object.getPrototypeOf(inject))) // @Inject 装饰器,继承父类声明
for (const name of Object.keys(inject)) result[name] = inject[name] ?? null
} else {
for (const name of Object.keys(inject)) result[name] = inject[name] ?? null // { tools: cfg }
}
return result
}
数组形式 = 只要服务;对象形式 = 服务 + 给它的 intercept 配置。dsh 里最典型的一行(packages/core/agent-loop/src/index.ts:297):
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
读这一行等于读到了 agent-loop 的全部外部依赖。五个名字,五个服务边界。没有隐式的 import,没有藏在深处的 require。这是 dsh 架构可读性的主要来源之一:想知道一个包依赖什么,看它的 inject。
3.2 epoch:依赖变了怎么办
inject 不只是"启动顺序"。看 Fiber._refresh()(vendor/cordis/src/fiber.ts:611):
_refresh() {
let epoch: string | boolean = false
epoch = ''
for (const name of Object.keys(this.inject)) {
const impl = this._store[name]
if (!impl) {
epoch = INACTIVE // 任一依赖缺失 → 整个插件不激活
break
}
epoch += ':' + impl.fiber.uid // ← 把依赖的 fiber uid 串成指纹
}
this._setEpoch(epoch)
}
epoch 是依赖实现的身份指纹:":3:7:12" 表示"我依赖的三个服务分别由 uid 为 3、7、12 的 fiber 提供"。然后(fiber.ts:625):
private _setEpoch(epoch: string) {
const oldEpoch = this._runner.epoch
if (epoch === oldEpoch) return // 指纹没变 → 什么都不做
this._runner.epoch = epoch
if (this.inertia) return // 正在做生命周期迁移 → 让它自己收敛
this._updateState(() => {
if (epoch !== INACTIVE && oldEpoch === INACTIVE) {
this.inertia = this._reload()
return FiberState.LOADING
} else {
this.inertia = this._unload()
return FiberState.UNLOADING
}
})
}
于是三件事自动成立:
- 依赖还没到 → 插件停在
PENDING,插件代码一行都没跑; - 依赖到了 → 自动
LOADING → ACTIVE; - 依赖被换了(uid 变了)→ 指纹变化 → 自动卸载再重装。
第三条是热重载的全部秘密。你在 cordis.patch.yml 里把 dsh-fs-local 换成 dsh-fs-e2b,ctx.fs 的提供者 fiber uid 变了,所有 inject: ['fs'] 的插件的 epoch 都变了,于是它们全部自动重装一遍——没有任何一行代码需要处理"我的依赖被换了"这种情况。
这就是第 8 章"Capability Seam"能成立的底层机制。
4. Fiber:六态状态机
本节和下一节的整体图示:
assets/ch02-fiber-lifecycle.svg(epoch 指纹、effect 反序回滚、六态迁移、inertia 自收敛)
一次 ctx.plugin(SomePlugin, config) 产生一个 Fiber(registry.ts:316)。Fiber 是"这个插件的这一次运行实例"。同一个插件被加载两次 = 两个 Fiber,共享一个 Plugin.Runtime(registry.ts:136)。
状态定义(fiber.ts:147):
export const enum FiberState {
PENDING, // 等依赖
LOADING, // 插件回调正在跑
ACTIVE, // 已加载,正在提供服务
FAILED, // 回调或配置校验抛了
DISPOSED, // 已移除,不能再启动
UNLOADING, // 正在跑 disposer
}
状态不是手工设置的,而是推导出来的(fiber.ts:574):
private _getState() {
if (this.uid === null) return FiberState.DISPOSED
if (this._error) return FiberState.FAILED
if (this._runner.epoch !== INACTIVE) return FiberState.ACTIVE
return FiberState.PENDING
}
四个 if,优先级从高到低。uid 为 null 是"已销毁"的唯一判据;有错就是 FAILED;epoch 有效就是 ACTIVE;否则在等依赖。这种"状态是纯函数"的写法比手工 this.state = X 的状态机可靠得多——不存在忘记转移的路径。
4.1 inertia:迁移中的"惯性"
_reload() 和 _unload() 是异步的,而依赖可能在迁移进行中再次变化。Cordis 的处理办法是一个叫 inertia 的 in-flight promise,配上迁移结束时的重新检查(fiber.ts:646):
private async _reload() {
this.store = { ...this._store }
const oldEpoch = this._runner.epoch
try {
await Promise.resolve()
// A disposer queued before this checkpoint may already have invalidated
// the load. Do not run plugin code for a stale epoch; ...
if (this._runner.epoch === oldEpoch) { // ← 检查一
this.config = this._resolveConfig(this._config)
await this._execute(this._runner)
this._error = undefined
}
} catch (reason) {
this.ctx.logger.error(reason)
this._error = reason
this._runner.epoch = INACTIVE
}
this._updateState(() => {
if (this._runner.epoch === oldEpoch) { // ← 检查二
this.inertia = undefined
} else {
this.inertia = this._unload() // 期间又变了 → 立刻掉头
return FiberState.UNLOADING
}
})
}
_unload() 是对称的(fiber.ts:675)——卸完之后如果发现 epoch 又变回有效了,就立刻 _reload()。
两个函数互相尾调用,构成一个自收敛的迁移循环。 中间无论依赖抖动多少次,最终一定停在与当前 epoch 一致的稳定态。这个模式(异步迁移 + 迁移前后各检查一次"世界是否还是我出发时的样子")在 dsh 里反复出现,第 4 章会在 agent loop 的 turnEnds && inbox.nextStep.length === 0 双重检查上看到同一个思路。
注意 _reload 里 catch 的处理:插件启动失败不会炸掉整个应用,只是这个 fiber 进 FAILED,错误被记下来,await fiber 时才抛。一个坏插件不拖垮进程。
4.2 卸载:并发跑所有 disposer,各自吞掉异常
private async _unload() {
await Promise.all(this._disposables.clear().map(async (dispose) => {
try {
await composeError(async (info) => {
await Promise.resolve()
info.error = new Error() // 为异步栈补上一段可读的调用栈
await runDisposable(dispose)
}, this._runner.getOuterStack)
} catch (reason) {
this.ctx.logger.error(reason) // 一个 disposer 抛了,不影响其他 disposer
}
}))
...
}
两个细节值得学:
_disposables.clear()先清空再遍历——避免 disposer 执行过程中往列表里加东西导致的重入问题;- 每个 disposer 单独 try/catch。清理阶段最忌讳"第一个清理失败导致后面的都不清理了"。
4.3 update:改配置 = 重启,但可以被拦
update(config: any, noSave = false) {
this.assertActive()
this._config = config
if (this.state !== FiberState.ACTIVE) {
// Config resolution may access injected services, so defer it until the
// fiber can activate.
this._error = undefined
this._setEpoch(INACTIVE)
this._refresh()
return
}
config = this._resolveConfig(config)
return this.context.waterfall(this, 'internal/update', config, noSave, () => {
this.config = config
this._error = undefined
return this.restart()
})
}
默认语义是"重启插件",但它包在一个 waterfall 里——所以 HMR 插件可以监听 internal/update,不调用 next() 来阻止重启,自己做更细粒度的热更新。框架的默认行为本身就是一个可替换的扩展点,这是 Cordis 反复使用的手法。
5. Effect:本章最重要的 API
5.1 契约
ctx.effect(execute, label)(fiber.ts:415)的 JSDoc 说清了全部语义:
`execute` runs immediately; the disposers it produces are collected and
run (in reverse order) either when the returned disposer is called or
when the fiber unloads, whichever comes first. Calling the disposer twice
is a no-op.
四条:立即执行、反序回滚、两个触发源(手动 dispose / fiber 卸载)取先到者、重复 dispose 是 no-op。
execute 的返回值可以是四种形态(fiber.ts:356 的 _execute):
| 返回 | 含义 |
|---|---|
void |
没有需要清理的东西 |
Disposable(函数) |
单个清理动作 |
Iterable(generator) |
每个 yield 出来的都是一个清理动作,按 yield 顺序收集、反序执行 |
AsyncIterable |
同上,异步版 |
generator 形态是最好用的。dsh 里的实例(packages/core/tools/src/index.ts:951):
const dispose = ctx.effect(function* (this: ToolRuntime) {
yield this.layers.effect(
ctx,
(layer) => {
if (layer.mode !== undefined) {
throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this scope; ...`)
}
layer.mode = mode
return () => { layer.mode = undefined }
},
{ label: 'tools.presentAs()' },
)
if (mode !== 'native') {
yield ctx.systemPrompt.section(this.collapseSection())
yield ctx.systemPrompt.section(this.sdkSection())
}
}.bind(this), 'tools.presentAs()')
读一遍这段代码的语义:tools.presentAs('code') 做了三件事——设置本 scope 的呈现模式、注册两个系统提示词 section。三件事被 yield 到同一个 effect 里。调用返回的那一个 disposer,三件事按反序全部撤销。
如果没有 effect,这段代码得长成这样:
// 手写版本:容易漏、容易错
const d1 = setMode(mode)
let d2, d3
try {
if (mode !== 'native') {
d2 = ctx.systemPrompt.section(...)
d3 = ctx.systemPrompt.section(...)
}
} catch (e) {
d1() // 记得回滚前面的
throw e
}
return () => { d3?.(); d2?.(); d1() } // 记得反序
ctx.effect + generator 把这套样板收敛成了语言级别的顺序表达。"如果 teardown 顺序重要,就把相关的工作放进同一个 effect" —— 这是 primer 里明确给出的实践规则。
5.2 为什么实现有 140 行
effect() 的实现(fiber.ts:418-561)比它的契约复杂得多。原因全在竞态:
竞态一:setup 还没跑完,owner 就开始卸载了。
// Make the effect visible to a reentrant owner unload before execute()
// runs any plugin code.
removeWrapper = this._disposables.push(wrapper)
try {
task = this._execute(runner)
} catch (reason) { ... }
注意顺序:先把 wrapper 挂到 fiber 的 disposables 上,再执行 execute()。反过来写的话,如果 execute() 内部(比如 await 了一下)期间 fiber 被卸载,这个 effect 还没入账,就漏了。
竞态二:setup 同步抛异常,同时 owner 已经捕获了 wrapper 但还没调用。
const wrapper = defineProperty(() => {
// A synchronous setup failure can race an owner unload that already
// captured this wrapper but has not invoked it yet. The failed effect is
// never returned publicly, so let that internal caller await rollback.
if (!runner.epoch) return setupFailed ? inFlight : undefined
...
竞态三:一个人已经开始清理了,另一个人也想清理。
// Public effect disposers remain single-shot, but structural owners and outer
// effects must still be able to join a cleanup that another caller started.
const effectInertia = new WeakMap<Disposable, () => void | Promise<void>>()
function runDisposable(dispose: Disposable) {
const result = dispose()
return effectInertia.get(dispose)?.() ?? result
}
公开的 disposer 是 single-shot(调第二次返回 undefined),但框架内部的调用者需要能"加入"别人已经开始的清理并等它结束。effectInertia 这个 WeakMap 就是给内部调用者留的旁路:拿到同一个 in-flight promise。
这三段注释是很好的工程样本——注释解释的都是"为什么这个顺序不能改",而不是"这行代码做了什么"。这也是 dsh 自己的文档规范要求的:
Comments and docs state complete contracts and context, not reasoning transcripts. ... Do not comment on facts obvious from code.
5.3 EffectMeta:可观测的注册树
export interface EffectMeta {
label: string // 'ctx.on("tools/execute")' / 'tools.presentAs()'
children: EffectMeta[] // 嵌套 effect
}
collect 时会把子 effect 的 meta 挂进 meta.children(fiber.ts:448),getEffects()(fiber.ts:568)导出整棵树。
这解释了为什么 dsh 能做"运行时自省":模型可以问"当前这个 agent 上装了哪些东西",答案不是靠猜,而是遍历 fiber 树 + effect 树。第 12 章的 tool-cordis 就吃这个。
6. 事件:五种 dispatch
6.1 官方文档只写了四种
docs/cordis-primer.md 给的表:
| Mode | Awaited? | Dispatch Order | Has Return Value? |
|---|---|---|---|
emit |
No | 注册顺序 | No |
waterfall |
No | 注册顺序 | Yes |
parallel |
Yes | 并发 | No |
serial |
Yes | 注册顺序 | Yes |
但 Cordis 的类型里有五种(events.ts:32):
export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'
第五种 bail 没有出现在 dsh 的文档表里,因为它是 Cordis 框架自用的——internal/listener 就用它(events.ts:296):
const result = this.bail(this.ctx, 'internal/listener', name, listener, options)
if (result) return result
产品层的 typed event 只用四种。这个"框架能力 ⊋ 产品允许使用的能力"的差别,在读 dsh 时要留意:docs/ 描述的是dsh 给插件作者的契约,不总是 Cordis 的全部能力。
6.2 五个实现,全都很短
// events.ts:183
async parallel(...args: any[]) {
const results = await Promise.allSettled(this.dispatch('emit', args).map(async cb => cb(...args)))
const errors = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
if (errors.length) throw new AggregateError(errors.map(error => error.reason))
}
// events.ts:194
emit(...args: any[]) {
this.dispatch('emit', args).map(cb => cb(...args))
}
// events.ts:204
async serial(...args: any[]) {
for (const cb of this.dispatch('serial', args)) {
const result = await cb(...args)
if (isBailed(result)) return result
}
}
// events.ts:217
bail(...args: any[]) {
for (const cb of this.dispatch('bail', args)) {
const result = cb(...args)
if (isBailed(result)) return result
}
}
几个必须记住的行为差异:
emit 不 await,也不接管异常。 .map(cb => cb(...args)) 把返回的 promise 直接丢掉了。所以 emit 事件的 async 监听器抛异常 = unhandled rejection。Cordis 自己在需要安全 emit 的地方是手工兜的(fiber.ts:120):
/** Notify plugin teardown without allowing one observer to break ownership cleanup. */
function emitPluginDisposed(context: Context, fiber: Fiber) {
const args: any[] = ['internal/plugin', fiber]
let callbacks: Function[]
try {
callbacks = context.events.dispatch('emit', args)
} catch (error) {
context.logger.error(error)
return
}
for (const callback of callbacks) {
try {
const returned = callback(...args)
void Promise.resolve(returned).catch(error => context.logger.error(error))
} catch (error) {
context.logger.error(error)
}
}
}
结论:emit 事件的监听器里必须自己 try/catch。 dsh 里 session/event 是 emit 模式(packages/core/session/src/index.ts:76),所有监听 session event 做投影、持久化、telemetry 的插件,都遵守这条。
parallel 用 allSettled + AggregateError。 不是 Promise.all——一个监听器失败不会导致其他监听器被跳过,所有错误一起抛出来。这是"扇出通知"该有的语义。
serial 会短路。 isBailed(value) 的定义(events.ts:13):
export function isBailed(value: any) {
return value !== null && value !== false && value !== undefined
}
返回 null / false / undefined = 继续;返回任何其他值(包括 0、''!)= 停下并把它当结果。写 serial 监听器时返回 0 会意外短路,这是个真实的坑。
6.3 waterfall:10 行代码撑起 dsh 的一半
这是本章最该背下来的一段(events.ts:234):
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() // 启动链条
}
十行。理解它的关键是 args 的最后一项被换掉了:调用方传进来的"默认行为"被 pop 出去,换成了 next。每个监听器收到的最后一个参数都是这个同一个 next 闭包;cbs 是共享的数组,shift() 让每次调用往前走一格。
由此得到的性质:
| 监听器的写法 | 效果 |
|---|---|
return next() |
透传,下一个监听器(最终是默认行为)说了算 |
改一改参数对象再 return next() |
协作式修改——下游看到改后的值 |
const r = await next(); return transform(r) |
包裹——拿到下游结果再加工 |
return myOwnResult(不调 next) |
短路——默认行为根本不会执行 |
这就是所谓 around-middleware / 洋葱模型。dsh 把它用到了极致——所有"策略"性质的扩展点都是 waterfall:
| 事件 | 声明位置 | 短路意味着 |
|---|---|---|
agent/pre-step |
packages/core/agent/src/runtime-types.ts |
拒绝进入这一步(plan mode 就靠它) |
agent/request |
同上 | 完全替换模型调用配置 |
llm/stream |
packages/llm/llm/src/index.ts:65 |
不调真模型(录制回放、mock) |
tools/pre-execute |
packages/core/tools/src/index.ts:152 |
拒绝执行(审批拒绝、沙箱策略) |
tools/execute |
packages/core/tools/src/index.ts:163 |
换个地方执行(沙箱、远程) |
tools/post-execute |
packages/core/tools/src/index.ts:175 |
改写工具结果(截断、脱敏) |
system-prompt/assemble |
packages/core/system-prompt/src/index.ts |
完全替换提示词装配结果 |
internal/update |
vendor/cordis/src/events.ts:343 |
阻止插件重启(HMR) |
"短路是设计的一部分",primer 说得很直白:
For single-decision events, short-circuiting is the design. A policy listener can return without
next()when it owns the decision, while a listener that only annotates or observes must delegate.
反过来,忘记调 next() 就是最容易犯的错——你的监听器只想加个日志,结果把整个默认行为吞了。所以 dsh 的 AGENTS.md 把它写成了全大写的硬规范:
Waterfall listeners MUST call
next()to delegate; returning without it short-circuits the chain.
6.4 prepend 和 global
ctx.on(name, listener, options) 的两个选项(events.ts:112):
export interface EventOptions {
prepend?: boolean // 插到已有监听器前面
global?: boolean // 无视 context filter,永远收到事件
}
prepend: true 在 waterfall 里意味着"我在最外层"——能包裹所有其他监听器。primer 的建议是:
Use
prepend: trueonly when the listener must run before ordinary registrations.
因为它是全局稀缺资源:两个插件都想在最外层,顺序就又变得依赖加载顺序了。
7. dispatch():三行过滤,撑起 dsh 的 scoped dispatch
dispatch() 是所有五种模式的公共前置(events.ts:165):
dispatch(type: string, args: any[]) {
const thisArg = typeof args[0] === 'object' || typeof args[0] === 'function' ? args.shift() : null
const name: string = args.shift()
if (!name.startsWith('internal/')) {
this.emit('internal/dispatch', type, name, args, thisArg)
}
const filter = thisArg?.[Context.filter]
return (this._hooks[name] || [])
.filter(hook => hook.global || !filter || filter.call(thisArg, hook.ctx)) // ← 这一行
.map(hook => hook.callback.bind(thisArg))
}
关注最后三行。每个注册记录 Hook 都存了 ctx(events.ts:120),也就是**"谁注册的这个监听器"**。派发时如果 thisArg 上有 [Context.filter],就拿它去问每一个监听器的 ctx:你有资格收到这个事件吗?
Service 的默认 filter 只是比较 isolate 域(第 2.1 节)。但这个钩子是开放的——任何对象都可以提供 [Context.filter] 并作为 thisArg。dsh 正是在这里插入了它最巧妙的一处设计(packages/core/scope/src/index.ts:170):
export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined): Scoped<T> {
const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter]
const carrier = {
[CordisContext.filter](ctx: Context): boolean {
if (baseFilter !== undefined && !baseFilter.call(base, ctx)) return false
const tag = scopeOf(ctx)
if (tag === undefined) return true // 未打标签的监听器:全局收
for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) {
if (cursor === tag) return true // 沿 scope 链向上找
}
return false
},
}
carrierKeys.set(carrier, key)
return carrier as unknown as Scoped<T>
}
配合它上面那段注释:
a listener owned by an enclosing scope receives every descendant scope's events, which is what lets one standing composition observe each of the agents composed under it. A tag BELOW the dispatch key stays excluded — events flow up the chain, never down.
翻译成产品语言:
- 全局插件(未打 scope 标签)→ 收到所有 agent 的事件;
- preset / agent-team 级别的插件(标签在链上更靠上)→ 收到它旗下所有子 agent 的事件;
- 某个 agent 的私有插件 → 只收到这个 agent 的事件,收不到兄弟 agent 的,也收不到父级的。
"事件沿链向上流,从不向下"。这一条规则加上 Cordis 那三行 filter,就实现了"一个进程里跑多个 agent、各自有各自的工具集和策略、互不串台"。
再看 scopeTarget 返回的类型 Scoped<T>——注释说得很清楚:
@returns a carrier whose subject remains available only through event arguments.
carrier 只是个路由信封,真正的 subject(agent 本体)不挂在 this 上,只能从事件参数里拿。所以你在 dsh 源码里会看到大量这样的事件签名:
'tools/pre-execute'(this: Scoped<ToolRuntime>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
this 是 Scoped<ToolRuntime> —— 类型上告诉你"别想通过 this 拿 runtime,那只是个路由标记",你要的一切都在 exec 里。类型系统在这里替架构规则站岗。
8. 把五个原语串起来看一个真实场景
假设部署里有一行配置把 dsh-fs-local 换成了 dsh-fs-e2b(E2B 云沙箱的文件系统实现)。完整发生的事:
1. Loader 调用 fiber.update() 或直接卸载旧 row / 装载新 row
↓
2. 旧 dsh-fs-local 的 fiber 进 UNLOADING
→ _unload() 并发跑所有 disposer
→ 其中一个是 Service 构造时的 reflect.provide('fs', ...) 的 disposer
→ ctx.fs 从服务表上摘除
↓
3. reflect.notify(['fs']) → 所有 inject 了 'fs' 的 fiber 执行 _checkImpl('fs')
→ this._store['fs'] 被 delete
→ _refresh() → epoch = INACTIVE
→ _setEpoch → _unload()
→ 依赖 fs 的插件(read/write/edit 工具等)全部卸载,它们注册的工具从 ctx.tools 上摘除
(ctx.tools.register() 返回的 disposer 就是干这个的)
↓
4. 新 dsh-fs-e2b 的 fiber 加载 → super(ctx, 'fs') → reflect.provide('fs', ...)
→ notify(['fs'])
↓
5. 依赖 fs 的 fiber 再次 _checkImpl → _store['fs'] = 新 impl
→ _refresh() → epoch = ':47'(新 fiber 的 uid)
→ _setEpoch → _reload()
→ 工具重新注册到 ctx.tools
↓
6. 下一次 agent step 装配提示词时,ctx.tools 里已经是新的工具集
(第 6 章:工具 schema 是每 step 现场装配的,不是启动时快照)
整条链上没有一行"处理文件系统被替换"的代码。 没有 if (fsChanged),没有事件监听,没有重启进程。这是 inject + epoch + effect 三者组合的自然结果。
而这,就是 dsh 敢在 README 里写 "Everything is a Plugin" 的技术底气:不是"每个功能都放在单独的文件夹里",而是每个功能都能在运行时被换掉,并且换掉之后整个依赖闭包会自动重新收敛。
9. 代价
老实讲清楚这套设计的成本:
1. 调试栈变难看。 Proxy + effect + waterfall 三层间接之后,异常栈里全是框架帧。Cordis 为此专门做了 composeError / getOuterStack(fiber.ts:678 附近那段 info.error = new Error())来给异步栈补上"注册这个 effect 的地方在哪"——但仍然不如直接调用清楚。
2. 静态可读性下降。 ctx.fs.read(...) 里的 fs 到底是哪个实现,源码里看不出来,必须跑 dsh --dump-config 看装配结果。dsh 的对策是把配置装配做成可导出的(第 3 章)、把服务关系做成生成的文档(docs/capability-seams.md),这算是补救得比较到位的。
3. 生命周期的坑变多。 什么时候能调 ctx.effect()(fiber 不能是 UNLOADING,见 fiber.ts:420)、waterfall 忘了 next()、emit 监听器里 async 抛异常……这些都是新引入的错误类别。dsh 专门写了一篇 docs/defensive-patterns.md,并在 AGENTS.md 里要求"做生命周期、并发、子进程、teardown 相关的工作前先读它"。
4. 学习曲线。 一个新人要给 dsh 加一个工具,得先理解 Service/inject/effect/waterfall/scope 五个概念。这是 dsh 用 docs/cordis-primer.md + docs/cordis-tutorial/ 两篇专门文档在填的坑。
这四条都是真实成本。 是否值得,取决于你的产品是不是真的需要"运行时可替换能力 + 可卸载插件"。如果你做的是一个功能固定的 agent CLI,直接写死依赖会简单得多;如果你要做一个 harness——别人在上面装东西的平台——那这套代价基本是必付的。
10. 可以带走什么
抛开 Cordis 这个具体框架,这 2693 行里有五个可以直接搬到任何项目的想法:
① 状态是推导出来的,不是设置出来的。
_getState() 四个 if 就定义了完整状态机。别写 this.state = 'loading' 散落各处。
② 异步迁移要在前后各检查一次"世界还是我出发时的样子"。
_reload 里两次 epoch === oldEpoch 检查。这个模式适用于任何"异步操作 + 外部状态可能变化"的场景。
③ 依赖用身份指纹表示,指纹变了就整体重建。
epoch = ':3:7:12'。比"监听每个依赖的变化事件然后做增量更新"简单一个数量级,而且不会错。
④ 注册必须返回 disposer,并且组合注册要能作为一个整体撤销。 generator effect 是很优雅的表达方式。任何有插件/扩展的系统都该有这条纪律。
⑤ 把"默认行为"做成中间件链的最内层。
waterfall(..., () => defaultBehavior())。这样"扩展默认行为"和"替换默认行为"是同一个机制,不需要两套 API。Cordis 连自己的 fiber.update()(重启插件)都用这个手法包了一层。
本章引用的源码位置
| 位置 | 内容 |
|---|---|
vendor/cordis/src/context.ts:42 |
Context 类 |
vendor/cordis/src/context.ts:71 |
构造函数:返回 Proxy |
vendor/cordis/src/context.ts:99 |
extend() |
vendor/cordis/src/context.ts:121 |
isolate() |
vendor/cordis/src/context.ts:139 |
intercept() |
vendor/cordis/src/service.ts:11 |
Service 抽象基类 |
vendor/cordis/src/service.ts:42 |
构造即注册 |
vendor/cordis/src/service.ts:61 |
[symbols.filter]:isolate 域比较 |
vendor/cordis/src/service.ts:86 |
[symbols.resolveConfig]:intercept 配置合并 |
vendor/cordis/src/registry.ts:19 |
Inject 类型 |
vendor/cordis/src/registry.ts:37 |
@Inject 装饰器 |
vendor/cordis/src/registry.ts:71 |
Inject.resolve():三种声明形式归一 |
vendor/cordis/src/registry.ts:136 |
Plugin.Runtime:多 fiber 共享 |
vendor/cordis/src/registry.ts:316 |
registry.plugin():创建 fiber |
vendor/cordis/src/fiber.ts:96 |
EffectMeta |
vendor/cordis/src/fiber.ts:112 |
effectInertia:内部调用者加入清理 |
vendor/cordis/src/fiber.ts:120 |
emitPluginDisposed:安全 emit 的手工版本 |
vendor/cordis/src/fiber.ts:147 |
FiberState 六态 |
vendor/cordis/src/fiber.ts:356 |
_execute():四种 effect 返回形态 |
vendor/cordis/src/fiber.ts:418 |
effect() 实现 |
vendor/cordis/src/fiber.ts:568 |
getEffects() |
vendor/cordis/src/fiber.ts:574 |
_getState():状态推导 |
vendor/cordis/src/fiber.ts:611 |
_refresh():epoch 指纹 |
vendor/cordis/src/fiber.ts:625 |
_setEpoch() |
vendor/cordis/src/fiber.ts:646 |
_reload():双重 epoch 检查 |
vendor/cordis/src/fiber.ts:675 |
_unload():并发 + 各自 try/catch |
vendor/cordis/src/fiber.ts:736 |
update():重启包在 waterfall 里 |
vendor/cordis/src/events.ts:13 |
isBailed() |
vendor/cordis/src/events.ts:32 |
DispatchMode:五种 |
vendor/cordis/src/events.ts:112 |
EventOptions:prepend / global |
vendor/cordis/src/events.ts:165 |
dispatch():filter 三行 |
vendor/cordis/src/events.ts:183 |
parallel() |
vendor/cordis/src/events.ts:194 |
emit() |
vendor/cordis/src/events.ts:204 |
serial() |
vendor/cordis/src/events.ts:217 |
bail() |
vendor/cordis/src/events.ts:234 |
waterfall():十行 |
vendor/cordis/src/events.ts:329 |
Events:框架内置事件 |
packages/core/scope/src/index.ts:137 |
createScope() |
packages/core/scope/src/index.ts:170 |
scopeTarget():dsh 的 scoped dispatch |
packages/core/tools/src/index.ts:951 |
tools.presentAs():generator effect 实例 |
packages/core/tools/src/index.ts:1019 |
requireCodeRuntime():为什么用 ctx.get 而非 inject |
packages/core/agent-loop/src/index.ts:297 |
static inject 五个依赖 |
官方对应文档:docs/cordis-primer.md、docs/cordis-tutorial/index.md、docs/defensive-patterns.md。