L5 · 事件系统
Lesson 05 · 模块 B · 读懂

事件系统:四种派发模式
与三类事件。

L4 说过「一切皆插件」,而插件之间真正的话事方式是事件。 这一课把 Cordis 的四种派发模式讲透——尤其是 waterfall 的 next() 纪律—— 再学会按 session / agent / capability 三类事件域为需求选对事件。

精读 20 分钟 练习 10 分钟 · 6 题 前置 见课程首页
学完你将能够
  • 说清 emit / waterfall / parallel / serial 的等待与返回语义,为给定场景选对模式
  • 写出正确的 waterfall 监听器:何时必须 next() 委托,何时短路就是设计
  • 按 session / agent/* / capability 三类事件域,为「持久化、拦截、挂策略」选对事件
  • 用 declaration merging 和 @mode 声明新事件,并在生成矩阵里查到它的生产者与消费者
01 · Dispatch Modes

四种派发模式:一张契约表

一个事件用哪种模式派发,是这个事件公开约定的一部分——它决定监听器能否返回值、 能否并发运行、能否彼此短路。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 且有返回。 时序差异看下面这张图:

emit ctx.emit() 调用方 监听器 1 监听器 2 同步广播:调用方不等待 监听器按注册顺序执行 返回值被忽略 waterfall ctx.waterfall() 调用方 监听器 1 监听器 2 默认逻辑 next() next() next() 实线:控制权沿 next() 向内委托;虚线:返回值逐层包装返回。不调 next() 即短路。 parallel ctx.parallel() 调用方 await 监听器 1 监听器 2 监听器 3 并发执行 await 等全部完成 无返回值 serial ctx.serial() 调用方 监听器 1 监听器 2 按注册顺序 逐个等待 首个有效返回值 胜出,后续停止 虚线:监听器 1 返回了非 null/false/undefined 的值,监听器 2 不再运行。
图 5-1 · 四种派发模式时序对比:emit 发了就忘,waterfall 沿 next() 链传播,parallel 扇出后等齐,serial 按序等待直到出现有效返回值。

拿真实事件对号入座:session/eventfs/observedtools/resultemitsession/flushparallel(持久化与遥测一起刷盘); agent/turn-stoppingserial;而 agent/pre-stepagent/requesttools/executeapproval/request 都是 waterfall——凡是需要「拦截、包装、替换」的地方,一定是它。

判断依据

拿到一个陌生事件,先查它的模式:生成文档 docs/event-producer-consumer.zh.md 的「模式」列写着每个 harness 事件是 emit、waterfall、parallel 还是 serial。模式不知道,监听器一定写错。

02 · Waterfall Deep-dive

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(),不会报错、不会告警——它只是悄无声息地吞掉所有下游默认行为, 模型请求凭空消失,你盯着日志找不出原因。

陷阱 · 短路 ≠ bug

不调 next() 并非总是错误:当监听器拥有决定权时,短路就是设计本身。 approval/request 允许策略插件代替用户作答——策略拒绝时直接返回,不再问用户; agent/request 允许插件替换模型调用配置。判断标准只有一条:这一层是「旁观者」还是「决策者」。 旁观者必须委托,决策者可以否决。

还有个顺序细节:协作式监听器通常修改共享的请求或决策对象再委托;prepend: true 只用于必须在普通注册之前运行的监听器,平时不要碰。

03 · Event Domains

三类事件域:改动的第一个决定

docs/architecture.zh.md 说得很直接:事件就是扩展点,而选对事件域是大多数改动的第一个决定。 harness 的事件分属三个域,各自的用途边界清晰:

  • session 事件 = 落盘并广播的持久事实。追加到会话日志、经 session/event 广播。 当一个事实必须在 reload 之后仍然存在时,只能用它。turn/*step/*tool/calltool/resultcompaction/* 都是持久会话事件类型—— 观察它们要监听 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 事件。先选域,再选具体事件,最后才写监听器。

04 · Typed Contract

typed events 契约:declaration merging 与 @mode

Cordis 事件的类型安全靠 TypeScript declaration merging:每个包往同一张 interface Events 里合并自己的键(merge-extensible map),于是 ctx.onctx.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 的「注册是可逆的副作用」是同一条原则。

05 · Try It

动手试:监听一个 capability 事件

目标:写一个 30 行的小插件,用 ctx.on 监听 capability 事件 tools/result 并打印;然后到生成矩阵里核对这个事件的生产者与消费者。

动手试 · 10 分钟
  1. 把下面插件存为 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-instructionssubagent-in-process-drivertool-present——你的 tool-logger 现在也是消费者之一。想换个事件观察?试试 fs/observed: 它的生产者是 tool-fs / tool-str-replace-editor,监听方有 fs-observation-policyskill-filesystemworkspace-files

06 · Quiz · 10 分钟

课堂练习

2 单选 + 2 多选 + 2 问答。选择即时批改,问答先写下你的答案再对照。成绩保存在本地浏览器。
单选
一个 waterfall 监听器没有调用 next() 就直接返回了,后果是什么?

waterfall 是环绕中间件,next() 是把控制权交给下游的唯一方式;不调用就直接 return 即为短路(否决)。框架既不报错也不代你委托——正文强调过,这正是「忘调 next()」危险的原因:它静默吞掉所有下游默认行为。

单选
要观察或拦截进行中的 agent 工作(比如改写即将发给模型的消息),该选哪类事件?

正文「三类事件域」:agent/* 携带活跃 Agent(inbox、步骤、状态、请求),专为观察/拦截进行中的工作而设,agent/pre-step 就是改写待领消息的例子。session 事件是已落盘的持久事实,适合「必须活过 reload」的场景;capability 事件挂在能力 seam 上。选域是大多数改动的第一个决定,三者并不等价。

多选
下列「模式 ↔ 语义」的配对,哪些是正确的?

B 错:并行执行是 parallel 的语义,waterfall 是按注册顺序串起的中间件链,值沿 next() 传播。D 错:serial 有返回值——第一个非 null/false/undefined 的返回值胜出并停止后续;无返回值的是 emit 和 parallel。A、C 与 primer 的分发模式表一致。

多选
关于 harness 的事件契约,哪些说法正确?

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/executetools/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 与遥测的共同源头。