事件系统:四种派发模式
与三类事件。
L4 说过「一切皆插件」,而插件之间真正的话事方式是事件。
这一课把 Cordis 的四种派发模式讲透——尤其是 waterfall 的 next() 纪律——
再学会按 session / agent / capability 三类事件域为需求选对事件。
- 说清 emit / waterfall / parallel / serial 的等待与返回语义,为给定场景选对模式
- 写出正确的 waterfall 监听器:何时必须
next()委托,何时短路就是设计 - 按 session /
agent/*/ capability 三类事件域,为「持久化、拦截、挂策略」选对事件 - 用 declaration merging 和
@mode声明新事件,并在生成矩阵里查到它的生产者与消费者
四种派发模式:一张契约表
一个事件用哪种模式派发,是这个事件公开约定的一部分——它决定监听器能否返回值、
能否并发运行、能否彼此短路。Cordis 一共提供五种模式(bail 按注册顺序同步观察、
直到某个监听器返回 bail 值,cordis-primer 的分发表已正式列出它),harness 日常打交道的是其中四种:
// emit — 同步广播:不等待、不收集返回值,「发了就忘」
ctx.emit('session/event', entry)
// parallel — 所有监听器并发运行,调用方 await 等全部完成,无返回值
await ctx.parallel('session/flush')
// serial — 按注册顺序逐个等待;第一个非 null/false/undefined 返回值胜出并停止后续
await ctx.serial('agent/turn-stopping', turn)
// waterfall — 环绕中间件:监听器收 (...args, next),值沿 next() 传播,有返回值
const result = await ctx.waterfall('agent/request', req, async () => req)
对照 docs/cordis-primer.zh.md 的分发模式表:emit 不 await、无返回;
waterfall 有返回;parallel await 但无返回;serial await 且有返回。
时序差异看下面这张图:
拿真实事件对号入座:session/event、fs/observed、tools/result 是
emit;session/flush 是 parallel(持久化与遥测一起刷盘);
agent/turn-stopping 是 serial;而 agent/pre-step、
agent/request、tools/execute、approval/request 都是
waterfall——凡是需要「拦截、包装、替换」的地方,一定是它。
拿到一个陌生事件,先查它的模式:生成文档 docs/event-producer-consumer.zh.md 的「模式」列写着每个 harness 事件是 emit、waterfall、parallel 还是 serial。模式不知道,监听器一定写错。
waterfall 深入:next() 是唯一的委托方式
waterfall 是实现拦截的模式。每个监听器收到 (...args, next):
调用 next() 才会执行下游监听器,直到最内层的默认逻辑(传给
ctx.waterfall 的最后一个函数);下游的返回值经 next() 回到本层,
可以包装后继续向外返回。不调用 next() 直接 return,就是短路——
Cordis 文档把这种行为称为「否决」。
// 改编自 docs/cordis-tutorial/04-events.zh.md 的 waterfall-demo
ctx.on('demo/transform', async (input, next) => {
const downstream = await next() // 委托下游,拿到下游返回值
return downstream.toUpperCase() // 包装后继续向外返回
})
ctx.on('demo/transform', async (input, next) => {
if (input.includes('blocked')) return '** blocked **' // 不调 next():短路
return next() // 只观察:必须委托
})
console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello'))
// → HELLO(监听器 1 → 监听器 2 → 默认逻辑,返回途中被转为大写)
console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words'))
// → ** BLOCKED **(监听器 2 短路,默认逻辑从未运行)
由此得到本仓库的常设纪律:只负责观察或标注的 waterfall 监听器必须调用 next()。
一个日志监听器如果忘了 next(),不会报错、不会告警——它只是悄无声息地吞掉所有下游默认行为,
模型请求凭空消失,你盯着日志找不出原因。
不调 next() 并非总是错误:当监听器拥有决定权时,短路就是设计本身。
approval/request 允许策略插件代替用户作答——策略拒绝时直接返回,不再问用户;
agent/request 允许插件替换模型调用配置。判断标准只有一条:这一层是「旁观者」还是「决策者」。
旁观者必须委托,决策者可以否决。
还有个顺序细节:协作式监听器通常修改共享的请求或决策对象再委托;prepend: true
只用于必须在普通注册之前运行的监听器,平时不要碰。
三类事件域:改动的第一个决定
docs/architecture.zh.md 说得很直接:事件就是扩展点,而选对事件域是大多数改动的第一个决定。
harness 的事件分属三个域,各自的用途边界清晰:
- session 事件 = 落盘并广播的持久事实。追加到会话日志、经
session/event广播。 当一个事实必须在 reload 之后仍然存在时,只能用它。turn/*、step/*、tool/call、tool/result、compaction/*都是持久会话事件类型—— 观察它们要监听session/event并检查event.type,它们不是同名的 Cordis 事件。 agent/*= 携带活跃 Agent 的在途事件。inbox、步骤、状态、请求、续跑。 要观察或拦截进行中的工作时用它——比如agent/pre-step能改写或拒绝即将发给模型的消息。- capability 事件(
fs/*、tools/*、telemetry/*)= 不 import loop 就能挂策略。 策略和适配器直接挂在某个能力 seam 上:fs/write-intent(waterfall)让策略在写盘前介入,tools/result(emit)让任何插件观察工具结果,agent loop 对这些插桩一无所知。
事实要活过 reload → session 事件;要碰进行中的 agent 工作 → agent/* 在途事件;
要给某个能力挂策略/适配器 → capability 事件。先选域,再选具体事件,最后才写监听器。
typed events 契约:declaration merging 与 @mode
Cordis 事件的类型安全靠 TypeScript declaration merging:每个包往同一张
interface Events 里合并自己的键(merge-extensible map),于是
ctx.on 和 ctx.emit 都有完整类型。命名遵守扁平的
namespace/action 约定:
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* 每次计数变化时广播。
* @mode emit
*/
'stats/report'(name: string, count: number): void
}
}
注意 JSDoc 里的 @mode 标签——它不是注释装饰,而是公开契约的一部分。
仓库的生成目录(docs/event-producer-consumer.zh.md 以及子系统页面的
cordis-surface 区块)由 TypeScript Program 解析出来,会拿声明里的
@mode 对照实际派发调用点交叉校验:声明写 emit 却用
waterfall 派发,会被揪出来。所以派发模式不是实现者可以随手改的实现细节。
最后记住一条生命周期事实:ctx.on() 注册的是 effect——插件卸载时监听器自动移除,
永远不需要手动维护 removeListener。这与 L4 的「注册是可逆的副作用」是同一条原则。
动手试:监听一个 capability 事件
目标:写一个 30 行的小插件,用 ctx.on 监听 capability 事件
tools/result 并打印;然后到生成矩阵里核对这个事件的生产者与消费者。
- 把下面插件存为
tmp/tool-logger.ts(示例出自docs/user/develop/framework/events.zh.md):
// tmp/tool-logger.ts
import type { Context } from '@deepseek-ai/cordis'
import '@deepseek-ai/dsh-tools'
export const name = 'tool-logger'
export function apply(ctx: Context) {
ctx.on('tools/result', (exec, result) => {
console.log(`[tool] ${exec.name}(${JSON.stringify(exec.arguments)})`)
})
}2. 用 L2 学的 overlay 手法把它加进组合(一行 - name: './tmp/tool-logger.ts'),跑一次会触发工具调用的 headless 任务:
pnpm dsh --profile headless "读一下 README.md 的前 5 行"3. 打开生成矩阵,确认 tools/result 这一行的派发方与监听方:
grep -n 'tools/result' docs/event-producer-consumer.zh.md
你会看到:模式是 emit,派发方是 tools 包(events.dispatch),
现有监听方包括 agent-instructions、subagent-in-process-driver 与
tool-present——你的
tool-logger 现在也是消费者之一。想换个事件观察?试试 fs/observed:
它的生产者是 tool-fs / tool-str-replace-editor,监听方有
fs-observation-policy、skill-filesystem 与 workspace-files。
课堂练习
next() 就直接返回了,后果是什么?waterfall 是环绕中间件,next() 是把控制权交给下游的唯一方式;不调用就直接 return 即为短路(否决)。框架既不报错也不代你委托——正文强调过,这正是「忘调 next()」危险的原因:它静默吞掉所有下游默认行为。
正文「三类事件域」:agent/* 携带活跃 Agent(inbox、步骤、状态、请求),专为观察/拦截进行中的工作而设,agent/pre-step 就是改写待领消息的例子。session 事件是已落盘的持久事实,适合「必须活过 reload」的场景;capability 事件挂在能力 seam 上。选域是大多数改动的第一个决定,三者并不等价。
B 错:并行执行是 parallel 的语义,waterfall 是按注册顺序串起的中间件链,值沿 next() 传播。D 错:serial 有返回值——第一个非 null/false/undefined 的返回值胜出并停止后续;无返回值的是 emit 和 parallel。A、C 与 primer 的分发模式表一致。
B 错:模式决定监听器能否返回值、能否短路,改了它会悄悄破坏所有现存监听器,是公开契约而非实现细节。C 错:harness 事件必须通过 declaration merging 登记进 interface Events,生成矩阵正是按声明解析的,未登记的事件不在契约内。A、D 即正文「typed events 契约」一节的原意。
问答waterfall 监听器为什么必须调用 next()?什么情况下又可以不调?写下答案后点击对照›
参考要点:① waterfall 是环绕中间件,只有 next() 会执行下游监听器与最内层默认逻辑,下游返回值也经 next() 回到本层供包装后向外返回;② 只观察/标注的监听器不调 next() 不会报错,而是静默吞掉全部下游行为,因此「旁观必须委托」是仓库常设纪律;③ 当监听器拥有决定权时,不调 next() 直接返回是有意的短路(否决)——如 approval/request 中策略拒绝时代替用户作答,此时短路本身就是设计。
问答三个场景各选哪类事件域?① 持久化一个事实;② 拦截一次工具调用;③ 给文件系统挂一条策略。说明理由。写下答案后点击对照›
参考要点:① session 事件——追加到日志并经 session/event 广播的持久事实,事实要活过 reload 时只能选它;② capability 事件 tools/*(如 tools/execute 或 tools/pre-execute,均为 waterfall)——不 import agent loop 就能在工具 seam 上拦截,监听器拥有决定权时可短路;③ capability 事件 fs/*(如 fs/write-intent / fs/edit-intent 介入写盘前,或 fs/observed 做观察)——策略挂在文件系统 seam 上,loop 对插桩一无所知。评分时看是否先选域、理由是否命中「持久 / 在途 / seam 策略」的分工。
本课小结
四种派发模式的语义、waterfall 的 next() 纪律、三类事件域的分工、
@mode 与 declaration merging 构成的契约——插件间通信的全貌已经在你手里了。
下一课我们放大三类事件中最重的那一类:session 事件,看「只增日志」如何成为模型上下文、
fork/resume、transcript 与遥测的共同源头。