读完这篇你能:订阅 DSH 内部的事件(工具调用、审批结果、会话记录),把计数挂到一条 HTTP 路由上;并且知道五种分发模式各自该用在什么地方。
前置:08-替换内置能力的提供方与消费者。约 30 分钟。
示例代码:dsh-event-log
DSH 版本:0.2.0-rc.2
08-替换内置能力的提供方与消费者那个探针是”我发起一次搜索,看结果”。这篇反过来:模型自己跑起来之后,我看不见它做了什么。
我想知道的很具体——这一轮它调了哪些工具、各几次、花了多久、失败几次;审批被拒了几次;哪条记录落进了会话文件。这些都不用改模型、也不用翻日志:DSH 内部每一步都会发事件,订阅就行。
目标
- 一个插件订阅三类真实事件,把计数挂到
/dsh-event-log/stats;一轮结束再往日志里打一行汇总。 - 一个演示插件和一个探针,把五种分发模式的差别用六次 curl 看全。
- 全部代码在
dsh_plugin/dsh-event-log/,装法与 08-替换内置能力的提供方与消费者同一个 profile。
DSH 设计
想看模型在做什么,不用改模型,也不用翻日志。DSH 内部每一步都会发「事件」:某个组件喊一声「这件事发生了」,谁想听谁挂一个「监听器」(一个回调函数)。喊的人不知道也不关心有几个监听器。这与服务调用正好相对——服务调用是指名道姓调一个方法、等返回值。
事件就是扩展点,而选对事件域是大多数改动的第一个决定。架构文档把事件分成三个域。「会话事件」是追加到会话日志(每次对话逐条落盘的事实记录)、再经一个广播口通知出去的持久事实,重启后还在。「agent 事件」的名字以 agent/ 开头,携带正在运行的那个对话实体,用来观察或拦截进行中的工作。「能力事件」是 tools/*、fs/* 这类,用来向某个能力缝(seam:一项可替换能力的接口层)附加策略,而不需要 import 它的实现。选域有句口诀:要「重启后还在的事实」找会话事件,要「趁还没发生改变它」找能力事件,要「跟着某个对话走」找 agent 事件。
定了域,再看事件本身怎么派发。框架规定:每个事件只属于一种「分发模式」——派发时等不等监听器、按什么顺序跑、收不收返回值,一共五种(广播、并发、顺序、短路、管道),且只能用对应的方法派发。分发模式因此是事件公开约定的一部分,不是派发方临场的选择。
五种里「管道」(waterfall)最特别:监听器除了参数还收到一个 next 函数,调它才放行后面的监听器和内置行为,不调就是否决。「拦截」这种用法由此而来。拿一个假想的「下单」事件来说:
ctx.on('order/submit', async (order, next) => {
if (order.amount > 10000) return '被拦下' // 不调 next():后面的监听器和真正下单都不发生
return next() // 放行:最里层才真正下单,返回值经这里往外传
})
这套约定还有工具链看守:新事件要用 @mode 标签在声明里写明模式,仓库生成的目录会把声明与实际派发点交叉校验。「一个事件名一种模式」不是惯例,是被工具链看守的约定。
监听本身是可逆的。框架里有五类东西——提示词片段、工具 schema、适配器、提供方和监听器——都以可逆 effect 的形式安装。DSH 把一个已加载的插件实例叫「fiber」:每个 effect 配好清理函数,实例卸载时它注册的一切按相反顺序撤销。挂监听器这个动作本身就登记在 fiber 上,所以插件被停用或重载时,监听器跟着消失。不需要派发方配合,也不需要自己记一本「我注册过哪些监听器」的账。
最后单独说会话事件的顺序:先落盘、后广播。它通知的是「已经追加到日志」的事实,所以监听器数到的条数能和磁盘上的日志逐行对上。它报道的是「已经发生过」,不是「将要发生」。
挑监听哪个事件,先问自己的事属于哪个域;五种分发模式则决定挂上去之后,监听器是旁观者还是关卡。
事件与它的契约
08-替换内置能力的提供方与消费者讲的是服务调用:消费者拿到 ctx.web,调它的 search(),一对一,要返回值。
事件是另一种通信方式:某个组件喊一声”这件事发生了”,谁想听谁听,喊的人不知道也不关心有几个监听器。
一个事件由三样东西构成,它们合起来就是事件的公开契约:
| 组成 | 例子 | 谁定 |
|---|---|---|
| 事件名 | tools/result |
声明它的那个包 |
| 参数 | (exec, result) |
同上 |
| 分发模式 | emit |
同上,写进声明的 @mode 注释 |
契约写在 TypeScript 的 interface Events 里,用模块声明合并到 Cordis 的 Events 接口上——Cordis 把所有包声明的事件拼成一张总表,ctx.emit / ctx.on 的名字和参数才有类型。DSH 仓库的 docs/event-producer-consumer.md 就是这张总表的成品:事件名、模式、谁派发、谁监听,一共一百多行。
声明自己的事件
要发自己的事件,先在自己的包里声明。src/events.ts 全文:
/**
* 我们自己声明的五个事件。
*
* 一个事件名只属于一种分发模式,所以五个模式各有自己的名字——这不是为了
* 演示而拆的,harness 里的事件就是这么定的(`tools/result` 永远是广播,
* `agent/pre-step` 永远是管道)。
*
* `declare module '@deepseek-ai/cordis'` 是类型层面的登记:Cordis 把所有
* 插件声明的事件合并成一张总表,`ctx.emit` / `ctx.on` 的名字与参数才有类型。
*/
import type {} from '@deepseek-ai/cordis'
/** 一次实验带上的编号,以及监听器往里写话的轨迹数组。 */
export interface LabEvent {
/** 本次实验的编号,探针每次请求自增。 */
readonly run: number
/** 令 `pipe` 的最外层监听器不调 `next()`,用来演示短路。 */
readonly veto: boolean
/** 监听器把"我跑了"写进这里;派发方读它,一次请求的结果就齐了。 */
readonly trace: string[]
}
declare module '@deepseek-ai/cordis' {
interface Events {
/** 广播:每个监听器都跑一遍,返回值没人看。 */
'event-lab/note'(payload: LabEvent): void
/** 并发:每个监听器都跑一遍,派发方等最慢的那个。 */
'event-lab/fanout'(payload: LabEvent): Promise<void>
/** 顺序:依次 await,某个监听器返回非空值就停下。 */
'event-lab/chain'(payload: LabEvent): string | void | Promise<string | void>
/** 短路:同步依次调用,某个监听器返回非空值就停下。 */
'event-lab/veto'(payload: LabEvent): string | void
/** 管道:监听器包住 `next()`,不调 `next()` 就否决后面的监听器与内置行为。 */
'event-lab/pipe'(payload: LabEvent, next: () => Promise<string>): Promise<string>
}
}
上面五个事件名各对应一种 派发 API(emit / parallel / serial / bail / **waterfall);前四个 API 名和语义大致同名,第五个在注释里常写 **管道,对应 'event-lab/pipe'。**waterfall 怎么工作、和 serial 的「停」有何不同,放在下一节 **五种分发模式 里讲,这里只关心类型怎么登记。
读懂 interface Events
上文「事件与它的契约」里说过:契约写进 interface Events,合并进 Cordis 的总表。代码块里做的是同一件事,有两点值得单独抠:
- 模块增强:
declare module '@deepseek-ai/cordis'的字符串必须和import ... from '@deepseek-ai/cordis'一致,否则ctx.on认不出你补的事件名。 - 接口合并:增强块里再写 同名
Events,成员 并进 包内已有接口(不覆盖别的插件已声明的名字)。插槽名必须是Events——改成MyEvents等,框架不会去读。登记服务时用**interface Context**,是同一套套路。
Cordis 把 ctx.on 和五种派发方法的参数类型都绑在这张表上。每一行是在约定 事件名 + 监听函数签名(例如管道那条多出来的 next,表示这类事件只该用 waterfall 派发)。
两处要说明:
**declare module里那五行不是装饰。** 没有它,ctx.emit('event-lab/note', payload)过不了类型检查;运行时靠类型擦除,声明会被剥掉,但它是编译期「这个事件存在、长什么样」的权威记录。真正监听、派发仍靠ctx.on/ctx.emit等。- 一个名字一种模式。 同一个名字不能
emit发一次、又waterfall发一次——派发方和监听方对next、返回值等预期必须一致;要换模式就换事件名(下节表格 + 注意事项还会提这一点)。
五种分发模式
五种 API 与上面五个 event-lab/* 事件名一一对应。若你刚看到 'event-lab/pipe' 里的 **next**,这里就是 **ctx.waterfall(管道)** 的完整说明。
| 模式 | 派发方法 | 等监听器吗 | 顺序 | 有返回值吗 |
|---|---|---|---|---|
| 广播 | ctx.emit |
不等 | 按注册顺序 | 无 |
| 并发 | ctx.parallel |
等,全部 | 一起跑 | 无 |
| 顺序 | ctx.serial |
等,逐个 | 按注册顺序,可能中途停 | 第一个非空值 |
| 短路 | ctx.bail |
不等 | 同上,同步 | 第一个非空值 |
| 管道 | ctx.waterfall |
看监听器 | 层层包裹 | 最外层的返回值 |
四组差别各自解决一个问题:
emit和parallel的区别是”等不等”:发通知(记一笔、广播状态)用emit,派发方马上继续;需要”都干完了再说”用parallel,比如session/flush要等所有持久化监听器落盘。serial和bail的区别是”派发方等不等你”:serial逐个await,监听器可以读磁盘、发请求;bail是同步的,它拿到监听器的返回值就当结果判断——所以bail事件里不要写async监听器,返回的 Promise 对象本身就算”非空值”,链会立刻停在它这里。Cordis 自己的internal/listener用的是bail:插件注册监听器时能被前面的插件拦下来。waterfall(管道):派发方调用ctx.waterfall(事件名, ...参数, 内置行为)——最里层是框架默认要做的事(例如「真正执行这个 tool」)。监听器除 payload 外还有next:先干自己的再await next()放行更里层,或 不调next()让后面监听和内置行为都不跑(否决)。先注册的在最外层,返回值从里往外包,最外层返回值即本次结果。它是唯一用next包住下游的;tools/pre-execute在工具执行前拦调用就是典型用法。serial的”停”和waterfall的”否决”不是一回事:前者是”我先说完了,后面的别跑了”,后者是”我包着后面的行为,我决定要不要让它发生”。
演示的监听器在 src/dsh-event-lab.ts(它只监听,不派发):
/**
* 演示用的五个监听器,一个分发模式一个。
*
* 这个插件不派发事件,只监听——派发方在 dsh-event-probe.ts 里。
* 两边除了事件名(和它带来的 payload 类型)之外互不认识,跟 08-替换内置能力的提供方与消费者的
* 「消费者只认接口」是同一回事。
*/
import { setTimeout as sleep } from 'node:timers/promises'
import type { Context } from '@deepseek-ai/cordis'
import type {} from './events.ts'
export const name = 'dsh-event-lab'
export function apply(ctx: Context): void {
// 广播:两个监听器都会跑,顺序就是注册顺序,返回值被忽略。
ctx.on('event-lab/note', (payload) => { payload.trace.push(`note-A(#${payload.run})`) })
ctx.on('event-lab/note', (payload) => { payload.trace.push(`note-B(#${payload.run})`) })
// 并发:两个异步监听器一起跑,谁先睡醒谁先写;派发方等最慢的那个。
ctx.on('event-lab/fanout', async (payload) => {
await sleep(120)
payload.trace.push(`fanout-A(#${payload.run},睡了 120ms)`)
})
ctx.on('event-lab/fanout', async (payload) => {
await sleep(20)
payload.trace.push(`fanout-B(#${payload.run},睡了 20ms)`)
})
// 顺序:依次 await;B 返回了一个字符串,C 不再执行。
ctx.on('event-lab/chain', async (payload) => { payload.trace.push(`chain-A(#${payload.run})`) })
ctx.on('event-lab/chain', (payload) => {
payload.trace.push(`chain-B(#${payload.run})`)
return 'chain-B 叫停'
})
ctx.on('event-lab/chain', (payload) => { payload.trace.push(`chain-C(#${payload.run})`) })
// 短路:同步版;A 返回了字符串,B 不再执行。
ctx.on('event-lab/veto', (payload) => {
payload.trace.push(`veto-A(#${payload.run})`)
return 'veto-A 叫停'
})
ctx.on('event-lab/veto', (payload) => { payload.trace.push(`veto-B(#${payload.run})`) })
// 管道:先注册的在最外层,最后兜底的是派发方传进来的那段"内置行为"。
ctx.on('event-lab/pipe', async (payload, next) => {
payload.trace.push(`pipe-A 进(#${payload.run})`)
if (payload.veto) {
payload.trace.push('pipe-A 没调 next,直接返回')
return '被 pipe-A 否决'
}
const inner = await next()
payload.trace.push('pipe-A 出')
return `A(${inner})`
})
ctx.on('event-lab/pipe', async (payload, next) => {
payload.trace.push('pipe-B')
const builtin = await next()
return `B(${builtin})`
})
// 07-配置与源码的热重载说过:ctx.logger 不往终端打,要肉眼可见的证据就自己 console.log。
console.log(`[${name}] 5 个监听器已就位`)
}
派发方在 src/dsh-event-probe.ts(它只派发,不监听):
/**
* 一扇窗:用 curl 派发一次自己声明的事件,把监听器的轨迹原样回传。
*
* 五种分发模式各派发一个事件,好让"广播 / 并发 / 顺序 / 短路 / 管道"
* 的差别在一次 curl 里看得见——真实插件不会为了看这个而发事件,这是
* 演示与排查用的探针。08-替换内置能力的提供方与消费者的探针也是这个角色。
*/
import type { Context } from '@deepseek-ai/cordis'
import type { LabEvent } from './events.ts'
import type {} from './events.ts'
import type {} from '@deepseek-ai/dsh-host-webserver'
export const name = 'dsh-event-probe'
export const inject = ['webServer']
const PATH = '/dsh-event-lab/fire'
export function apply(ctx: Context): void {
let run = 0
ctx.effect(() => ctx.webServer.register({
kind: 'exact',
path: PATH,
handler: async (req, res) => {
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
const mode = url.searchParams.get('mode') ?? 'emit'
const payload: LabEvent = {
run: ++run,
veto: url.searchParams.get('veto') === '1',
trace: [],
}
const startedAt = Date.now()
const returned = await fire(ctx, mode, payload)
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({
mode,
returned,
elapsedMs: Date.now() - startedAt,
trace: payload.trace,
}, null, 2))
},
}))
}
/** 按模式派发。`waterfall` 多传一个参数:链条最内层的"内置行为"。 */
async function fire(ctx: Context, mode: string, payload: LabEvent): Promise<unknown> {
switch (mode) {
case 'emit':
ctx.emit('event-lab/note', payload)
return null
case 'parallel':
await ctx.parallel('event-lab/fanout', payload)
return null
case 'serial':
return await ctx.serial('event-lab/chain', payload) ?? null
case 'bail':
return ctx.bail('event-lab/veto', payload) ?? null
case 'waterfall':
return await ctx.waterfall('event-lab/pipe', payload, async () => '内置行为')
default:
return `未知模式:${mode}`
}
}
web 起起来(端口挑一个没被占用的,下面沿用 08-替换内置能力的提供方与消费者的 3099),五个模式各打一枪——trace 就是每个监听器留给我的那句话:
for m in emit parallel serial bail waterfall; do
curl -s "http://127.0.0.1:3099/dsh-event-lab/fire?mode=$m"; echo
done
{
"mode": "emit",
"returned": null,
"elapsedMs": 0,
"trace": [
"note-A(#2)",
"note-B(#2)"
]
}
{
"mode": "parallel",
"returned": null,
"elapsedMs": 122,
"trace": [
"fanout-B(#3,睡了 20ms)",
"fanout-A(#3,睡了 120ms)"
]
}
{
"mode": "serial",
"returned": "chain-B 叫停",
"elapsedMs": 1,
"trace": [
"chain-A(#4)",
"chain-B(#4)"
]
}
{
"mode": "bail",
"returned": "veto-A 叫停",
"elapsedMs": 0,
"trace": [
"veto-A(#5)"
]
}
{
"mode": "waterfall",
"returned": "A(B(内置行为))",
"elapsedMs": 1,
"trace": [
"pipe-A 进(#6)",
"pipe-B",
"pipe-A 出"
]
}
(五段回包各自是缩进 JSON,这里并排折行了;#n 是探针每次请求自增的编号。)
逐条看:
emit的两个监听器都跑了,顺序是注册顺序,returned是null——广播不收集返回值。parallel花了 122ms,接近两个监听器里慢的那个(120ms),而trace里 B 先写:它们同时开始,谁先醒谁先写,派发方一直等到最后一个人写完。serial停在chain-B:C 一次都没跑,returned就是 B 返回的那个字符串。bail只跑了veto-A就返回了——同步派发,不await监听器。waterfall的trace是”进、内层、出”的顺序,returned是A(B(内置行为)):内层 B 先把派发方给的”内置行为”包了一层,外层 A 又包了一层,返回值一层层往外走。
管道还能短路,加一个 veto=1:
curl -s 'http://127.0.0.1:3099/dsh-event-lab/fire?mode=waterfall&veto=1'
{
"mode": "waterfall",
"returned": "被 pipe-A 否决",
"elapsedMs": 0,
"trace": [
"pipe-A 进(#7)",
"pipe-A 没调 next,直接返回"
]
}
B 和”内置行为”都没执行。这就是”否决”:不调 next() 就没有下游。
监听器的归属
ctx.on 会返回一个注销函数,但一般用不上。Cordis 里注册监听器是这样存的(vendor/cordis/src/events.ts):
register(label: string, hooks: Hook[], callback: any, options: EventOptions): () => void {
const method = options.prepend ? 'unshift' : 'push'
return this.ctx.fiber.effect(() => {
hooks[method]({ ctx: this.ctx, callback, ...options })
return () => this.unregister(hooks, callback)
}, label)
}
this.ctx.fiber.effect(...)——注册监听器本身就是一次 effect(05-插件生命周期与 ctx.effect的那种)。所以不需要自己写 ctx.effect 包一层,也不用维护”我注册过哪些监听器”的账本:插件卸载、重载、被 patch 停用,监听器跟着 fiber 一起摘掉。
验证方式是停掉 lab 那一行、留住探针行。在 profile 的 cordis.patch.yml 里写:
- id: dsh-event-lab
disabled: true
保存(配置热重载默认开着,不用重启),再打同一枪:
{
"mode": "emit",
"returned": null,
"elapsedMs": 0,
"trace": []
}
trace 空了,但路由还在——探针插件没被牵连。把 disabled 去掉,监听器回来。注意恢复后那次请求里编号是 #9:停用期间也打了两次枪,编号一直在涨,说明探针的 run 计数器(在它自己的 apply 里)从头到尾没被重建,只有 lab 那一行被卸载过。
还有两个选项:
ctx.on(name, listener, true)或{ prepend: true }:把这个监听器插到最前面。管道模式里”最前面”就是最外层——想否决别人,你得先被调用。{ global: true }:跳过作用域过滤。DSH 有些事件声明成this: Scoped<Agent>(”哪个 agent 触发的”),只有作用域里的监听器收得到;普通插件(没被圈进任何 agent 作用域)不受这个过滤,会收到所有 agent 的事件。本篇的统计插件就属于这种:它统计的是整进程。
统计真实事件
src/dsh-event-stats.ts 全文:
/**
* 统计真实发生在 DSH 里的事件,并把计数挂到一条 HTTP 路由上。
*
* 它监听三件不同的事:
* - `tools/execute`(管道):包住一次工具执行,量它花了多久;
* - `tools/result`(广播):一次调用结束,记成功/失败;
* - `session/event`(广播):每有一条会话记录落日志就触发一次。
*
* 三种用法各自对应一种分发模式的选择理由,正文里逐个说。
*/
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-user-approval'
export const name = 'dsh-event-stats'
// 故意不写模块级 inject:统计这件事本身不依赖任何服务,headless 里也要跑。
// 只有"把计数挂到路由上"这一段需要 webServer,所以用 04-注册 HTTP 路由与注入页面样式的写法单独等它。
const PATH = '/dsh-event-log/stats'
interface ToolStat {
calls: number
failures: number
totalMs: number
}
export function apply(ctx: Context): void {
const tools = new Map<string, ToolStat>()
/** 会话记录(写进 JSONL 的那些)按类型计数。 */
const recorded = new Map<string, number>()
/** 审批结果按 outcome 计数。 */
const approvals = new Map<string, number>()
/** callId → 本次调用耗时,由上面的管道写、下面的广播取。 */
const durations = new Map<string, number>()
let sessions = 0
// 管道:包住一次执行量时长。next() 的结果要原样返回(换个值就是替换
// 这次调用的结果),也不能不调 next()——那等于不让这次调用真的执行。
ctx.on('tools/execute', async (exec, next) => {
const startedAt = Date.now()
try {
return await next()
} finally {
durations.set(exec.callId, Date.now() - startedAt)
}
})
// 广播:此刻结果已经定型,监听器只观察、不改写。
ctx.on('tools/result', (exec, result) => {
const ms = durations.get(exec.callId)
durations.delete(exec.callId)
const stat = tools.get(exec.name) ?? { calls: 0, failures: 0, totalMs: 0 }
stat.calls += 1
if (result.isError) stat.failures += 1
stat.totalMs += ms ?? 0
tools.set(exec.name, stat)
console.log(`[${name}] ${exec.name} ${result.isError ? '失败' : '成功'}${ms === undefined ? '' : ` ${ms}ms`}`)
})
/** 当前的计数快照:路由用它,一轮结束时的那行日志也用它。 */
const snapshot = () => ({
sessions,
tools: Object.fromEntries([...tools].map(([toolName, stat]) => [toolName, {
...stat,
avgMs: Math.round(stat.totalMs / stat.calls),
}])),
approvals: sorted(approvals),
recorded: sorted(recorded),
})
// 广播:会话记录落一条触发一次。`event.type` 就是记录类型,`event.data` 是它的内容。
ctx.on('session/event', (_session, event) => {
bump(recorded, event.type)
if (event.type === 'approval/decided') {
bump(approvals, event.data.outcome)
}
// 一轮结束是个天然的结算点:headless 里没有路由可 curl,这行就是全部证据。
if (event.type === 'turn/end') {
console.log(`[${name}] 一轮结束:${JSON.stringify(snapshot())}`)
}
})
ctx.on('session/created', () => { sessions += 1 })
ctx.inject(['webServer'], (serverCtx) => {
serverCtx.effect(() => serverCtx.webServer.register({
kind: 'exact',
path: PATH,
handler: (_req, res) => {
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
res.end(JSON.stringify(snapshot(), null, 2))
},
}))
})
}
function bump(counter: Map<string, number>, key: string): void {
counter.set(key, (counter.get(key) ?? 0) + 1)
}
/** 次数多的在前;一样多就按名字。 */
function sorted(counter: Map<string, number>): Record<string, number> {
return Object.fromEntries([...counter].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])))
}
先把那段管道按时间轴拆开,看一次工具调用从头到尾经过它时发生了什么。假设模型要调 shell_exec:
- 工具服务决定执行,但先不碰工具本体。 它派发
tools/execute管道,把exec(这次调用的描述:工具名、参数、callId)连同next一起交给最外层监听器。 - 我们的监听器拿到控制权,工具还没执行。 第一行
Date.now()记下开始时间——这是”前”。 await next()把控制权交进内层。 如果还有别的管道监听器,先一层层过它们;最里面才是派发方给的”内置行为”——真正执行工具。next()的返回值就是这次调用的结果。await期间我们的监听器是挂起的,工具爱跑多久跑多久。- 内层全部结束,
await醒来,进finally。 不管内层是正常返回还是抛错,finally都跑,把耗时写进durations。这是”后”。 return把结果原样交还派发方。 工具服务接着走它的流程:冻结结果、派发tools/result广播——也就是下面那个监听器登场的时刻。
画成时序图是这样:
凭什么”写时长”一定在”读时长”之前? tools/execute 和 tools/result 看起来是两次独立的事件派发,凭什么一个一定跑在另一个前面?答案是:对工具服务来说,这根本不是两次事件,而是一个函数里的两行代码。把派发方(packages/core/tools/src/index.ts 里 dispatchScheduledExecution → finishScheduledExecution 那一串)剥到骨架,长这样:
async function 执行一次工具调用() {
const result = await 派发waterfall('tools/execute') // 管道监听器在这里面跑
// ……归一化、post-execute、定稿……
派发广播('tools/result') // 广播监听器在这里面跑
}
配置
dsh-event-log/ ← 插件包(bundle)
├── src/
│ ├── events.ts ← 五个事件的声明
│ ├── dsh-event-lab.ts ← 监听器(可以被单独停用)
│ ├── dsh-event-probe.ts ← 路由:派发一次、回传轨迹
│ └── dsh-event-stats.ts ← 监听真实事件 + 计数路由
├── package.json
├── patch.yaml
├── tsconfig.json
└── install.sh
package.json 只有多了几个依赖:类型从 dsh-tools、dsh-session、dsh-user-approval 来(后两个包分别声明了工具与会话记录的类型,而且都是 import type,运行时会被剥掉)。
"dependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
"@deepseek-ai/dsh-session": "^0.2.0-rc.2",
"@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
"@deepseek-ai/dsh-user-approval": "^0.2.0-rc.2"
}
本包的 patch.yaml 插三行:
- insert:
- id: dsh-event-lab
name: '/绝对路径/dsh-event-log/src/dsh-event-lab.ts'
- id: dsh-event-probe
name: '/绝对路径/dsh-event-log/src/dsh-event-probe.ts'
- id: dsh-event-stats
name: '/绝对路径/dsh-event-log/src/dsh-event-stats.ts'
装完不写 profile 覆盖层——这三个插件没有配置项。所以 install.sh 只做两件事:pnpm install、dsh plugin add。别让它去覆盖 cordis.patch.yml:那里面还留着 08-替换内置能力的提供方与消费者的换源配置。
cd <你的插件包目录>/dsh-event-log && ./install.sh
验证
配置看起来对:
dsh --profile test --dump-config | tail -10
# == dsh-event-log
- id: dsh-event-lab
name: >-
file:///绝对路径/dsh-event-log/src/dsh-event-lab.ts
- id: dsh-event-probe
name: >-
file:///绝对路径/dsh-event-log/src/dsh-event-probe.ts
- id: dsh-event-stats
name: >-
file:///绝对路径/dsh-event-log/src/dsh-event-stats.ts
web 起起来(端口挑一个没被占的),五种模式和”停用 lab 行”的实测就是上面那几段 curl。
真实事件得让模型跑起来才有。headless profile 里没有 HTTP,正好用来看 console.log 那两条线:
dsh --profile headless "两件事:1) 用 glob 列出 src 下所有 .ts 文件;2) 用 write 工具把一行 hello 写到 /etc/dsh-event-log-probe.txt。两步的结果都如实汇报。"
[dsh-event-lab] 5 个监听器已就位
dsh: warning: 1 entry did not activate
dsh-event-probe (…/src/dsh-event-probe.ts): pending (waiting for service: webServer)
[dsh-event-stats] glob 成功 25ms
[dsh-event-stats] write 失败 8ms
[dsh-event-stats] write 失败 11ms
[dsh-event-stats] 一轮结束:{…}
三行工具日志:glob 成功一次,write 失败两次(模型自己重试了一次)。pending (waiting for service: webServer) 那行是探针——headless 里没有 webServer,它就一直等,而统计插件不受影响。最后那行 JSON 折开来是:
{
"sessions": 1,
"tools": {
"glob": { "calls": 1, "failures": 0, "totalMs": 25, "avgMs": 25 },
"write": { "calls": 2, "failures": 2, "totalMs": 19, "avgMs": 10 }
},
"approvals": { "unavailable": 1 },
"recorded": {
"session-log-deepseek/delivery-accepted": 4,
"assistant/message": 3, "step/end": 3, "step/start": 3,
"tool/call": 3, "tool/result": 3,
"agent/inbox/spliced": 2, "session/title": 2, "user/message": 2,
"approval/asked": 1, "approval/decided": 1, "approval/policy": 1,
"permission/preset": 1, "request/context": 1, "request/header": 1,
"sandbox/mode": 1, "session/title-llm-request": 1, "system/message": 1,
"turn/end": 1, "turn/start": 1
}
}
approvals 里是 unavailable 而不是 rejected:headless 里没有审批通道,按 fail-closed 处理——没人能批,就是不批。换成有人在界面上点”拒绝”,同一个位置会记成 {"rejected": 1}。
recorded 这一坨就是”哪些事件会进 session 记录”的答案,而且能去磁盘上核对(会话日志是 zstd 压过的):
zstd -dc ~/.dsh/sessions/<工作区目录>/<session-id>/session.v4.jsonl.zstd \
| python3 -c 'import json,sys,collections;c=collections.Counter(json.loads(l)["type"] for l in sys.stdin if l.strip());print("\n".join(f"{n:3d} {t}" for t,n in sorted(c.items(),key=lambda kv:(-kv[1],kv[0]))))'
4 session-log-deepseek/delivery-accepted
3 assistant/message
3 step/end
3 step/start
3 tool/call
3 tool/result
2 agent/inbox/spliced
2 session/title
2 user/message
1 approval/asked
1 approval/decided
1 approval/policy
1 permission/preset
1 request/context
1 request/header
1 sandbox/mode
1 session
1 session/title-llm-request
1 system/message
1 turn/end
1 turn/start
和上面 recorded 一模一样,只多一行 session——文件第一行是会话头,不是事件。这条核对的意思是:session/event 不是”将要落盘”,是已经提交之后的通知,所以监听器数到的就是文件里的。
web profile 里同一个对象挂在路由上:
curl -s 'http://127.0.0.1:3099/dsh-event-log/stats'
进程刚起来、还没人聊过时它是空的:
{
"sessions": 0,
"tools": {},
"approvals": {},
"recorded": {}
}
在界面上让模型跑一轮,再 curl 一次,结构和 headless 那份 JSON 完全相同——它们是同一个 snapshot()。
注意事项
- 一个事件名一种模式。 用
ctx.emit去发一个声明为waterfall的事件(或者反过来),类型检查会拦你;真绕过去了,两边对next和返回值的理解也不一致。Cordis 文档里那句”分发模式是事件公开约定的一部分”就是这个意思。 - 监听器里的活儿要短。
emit和bail是同步派发:监听器函数体里同步的那部分跑完,派发方才继续。所以别在同步派发的监听器里同步做重活——要读磁盘、发请求,就在serial/parallel里写async,或者先把活儿丢出去再返回。parallel是并发派发,但监听器的错会被包成AggregateError抛回派发方。 - 广播的错被兜住,管道的错不兜。 观察者(
emit)抛错只留一条 warning;在waterfall里抛错,错就是派发方的错,会变成这次调用的失败。写拦截类监听器时这条是双刃剑:能拦是因为你在链上,出事也是你的事。 - 统计是整进程的。 没有作用域标签的插件会收到所有 agent、所有会话的事件,
sessions也会一直涨。要按会话分开算,就在session/event里按session.id分桶;要只算某个 agent,得在那个 agent 的作用域里注册监听器——那属于 subagent 的题目,本篇不展开。 - 想看事件有没有发出来,可以临时订阅 Cordis 的内部事件
internal/dispatch:它带(mode, name, args, thisArg),在每条非internal/事件派发之前触发一次,是排查用的快照工具。它不是 harness 事件,别用在长期代码里。 - 有些通知不是记录。
session/created、tools/change、llm/adapters-updated这类只用于”状态变了”的广播,不会出现在会话文件里——recorded里没有它们,是正常的。