DeepSeek Harness 进阶:监听事件流,会话自动出带哈希防伪日报
生命不息,折腾不止。上一篇教你在 Agent 动手前拦它,这一篇钻到它「记性」的底层——把一整轮对话自动压成一份带 SHA-256 防伪哈希的日报,交差、交接、审计一条龙。
上一课我们写了个钩子插件,在 tools/pre-execute 上挡在 Agent 前面当门卫。那次用的是「运行时事件」——瞬时的、只活在进程里的信号。但 dsh 真正有意思的东西藏在更底层:它的会话根本不是一条聊天记录数组,而是一条只追加、永不改写的日志。今天钻进去,顺手把「会话」炼成「交付物」。
一、先破一个成见:dsh 的「记忆」不是聊天记录,是事件流
很多人第一反应是把 Harness 的会话理解成 ChatGPT 那种 messages: [...] 数组——用户一句、模型一句,堆起来就完事。dsh 不是这么干的,它走的是**事件溯源(event sourcing)**这条路:
会话状态是一份由类型化
SessionEvent组成的仅追加日志(append-only log),是 Agent 整个交互历史的唯一真相源。模型看到的消息历史,是从这份日志派生出来的,从来不单独存第二份。
翻译成人话:日志是「账本」,模型能看到的对话是「从账本算出来的报表」。「回放」不是把报表调出来,而是拿同一批事件重新算一遍。
这对我们有什么好处?只要把「记账」这件事盯住,你就能拿到 Agent 做过的一切——说过什么、调过什么工具、哪一步改了主意,全都流水一样躺在日志里。想自动写日报、交接文档、审计轨迹,这层就是地基。
二、两条事件总线,务必要分清
动手前必须画清一条线,否则会写出「监听了半天啥也没收到」的灵异 bug。dsh 里存在两个事件平面:
| 平面 | 性质 | 典型 | 咋发 | 咋听 |
|---|---|---|---|---|
| A:Cordis 进程内(live) | 瞬时、插件间通信 | tools/pre-execute、agent/error |
ctx.emit('xxx') |
ctx.on('xxx') |
| B:SessionEvent 持久日志(durable) | 落盘、可重放的事实 | user/message、tool/result、turn/end |
session.append(type, data) |
只能通过 session/event 间接观察 |
关键结论:持久日志里的事件,你没法用 ctx.on('turn/end') 直接订阅——它压根不是 Cordis 事件。想观察「每一条落盘事实」,唯一的入口就是 session/event 这一条监听:
1 | ctx.on('session/event', (session, event) => { |
这个事件是 emit 模式——fire-and-forget,提交后异步回调。官方源码里 invokeContainedSessionObservers 把每个监听的异常单独 catch 掉、只 logger.warn,所以细节上记住两点就够:
- 先 commit、后通知:日志已经写进去了,回调才触发;回调里抛错不会把已经落盘的事件「吐」回去,错会被日志吃掉并单独记下来。
- 事件信封字段:每个落盘事件都带
type(类型)、seq(单调递增序号)、time(epoch 毫秒时间戳)、data(载荷);其中「会产生模型消息」的那几类才额外带surfaceOp(它怎么进到对话表面)和sourceEventSeqs(它由哪些事件生成)。
哪些类型会产生模型可见消息?官方把它们叫 SurfaceEventType,完整清单是五个:system/message(系统提示词)、developer/message(开发者指令)、user/message(用户输入)、assistant/message(模型回复)、tool/result(工具结果)。对写日报来说,我们主要盯后三个。其余像 turn/start、turn/end、step/start 这些,都是 log-only——会进日志、能重放,但不会变成模型上下文。
三、手写一个「会话日报」插件
理解了地基,直接上活。目标插件叫 session-diary:盯住会话事件流,一轮对话(turn)结束时,自动把它压成一份 Markdown 日报,算好 SHA-256 防伪哈希,落到 reports/ 目录。
第 1 步:订阅 session/event,按 event.type 分拣。
消息文本具体藏在哪,官方类型定义里每一类消息都是「分段数组」,纯文本段是 { type: 'text', text: '...' } 这样。精确字段以官方 packages/core/session/src/types.ts 为准,这里写一个防御性的 extractText 兜住:
1 | import { Context } from '@deepseek-ai/cordis' |
第 2 步:挂载跑起来。
上两课讲过挂载套路,照搬即可(--patch 覆盖层挂载,或打包后 dsh plugin add):
1 | # 覆盖层方式:把插件入口写进 patch 文件 |
跑起来后,随便聊一轮,切到 reports/ 目录,就能看到自动生成的 xxx-turn1.md,底部带着一串 sha256。
四、凭据怎么算、怎么验:一步锁死防伪,以及一个不能踩的坑
日报能不能当「交接文档」,光有内容是没分量的,得能证明「这份东西从生成后没被人动过」。答案就是哈希:
- 算:对日报正文算
sha256,createHash('sha256').update(markdown).digest('hex')得到 64 位十六进制串,写进日报尾部。 - 验:任何人拿到文档,把正文再算一遍,跟尾部留的哈希一比对,一致就说明原封未动,改一个标点都对不上。
这里我想跟你多啰嗦一句「把这个事件写回日志」这件事——因为它藏着一个真实的大坑,我差点栽进去。事件溯源的理想做法当然是「生成日报」也 session.append 回日志,让日报本身进入账本、可重放可审计。但你要是在下游插件(不在 dsh 仓库里、自己写的)里这么干,会踩到官方至今还在讨论的坑(GitHub discussion #802 / #5474):
下游插件可以靠 declaration merging 扩展
SessionEventMap,让session.append('report/generated', ...)编译通过、当场也能跑。但重启后,持久化加载会撞见一个它不认识的事件类型——而append又没法给这个事件打上ignorable: true的跳过标记——于是整个会话直接拒绝加载,抛SessionFormatUnsupportedError,你辛苦聊出来的会话打不开了。
换句话说,自定义事件 type 目前只在 dsh 仓库内部可靠,仓库外的插件写了会炸。所以稳妥做法就是本课这个 demo 的路子:只读不写——观察日志生成日报,日报落成普通文件,别反过来往账本里塞自造的事件类型。这事官方那边正在推 IgnorableSessionEventMap 的解决方案(#5463),等它落地了再回填「写回日志」的优雅版本。
真要上生产、要周报还要顺手出公众号文章,别重复造轮子,社区已经有现成的
dsh-report-studio(GitHub 搜 ciceroyang/dsh-report-studio)——它把「会话 → 日报/周报/交接文档 + 报告与产物双哈希凭据」这套做全了,v0.2 起还支持直接读多帧 zstd 历史日志做跨会话聚合。我们今天的session-diary是它的一套最小可懂版,看懂了这个,再去读它的源码就通透了。
五、实测踩坑清单
兄弟带你踩过的坑,记下来:
- 下游插件别
append自定义事件类型。编译能过、当场不报错,但重启后整个会话拒绝加载(上文ignorable那个坑)。要么只读不写,要么等官方的IgnorableSessionEventMap落地。 - 载荷必须可 JSON 序列化。
Session.append在源头就强校验,塞了个undefined或函数进去,当场抛异常、事件进不了日志——也算好事,保证账本干净。 session/event是 emit,回调出错不影响已提交的 append。别指望在回调里「拦截」掉一个已经落盘的事件,那已经晚了。要否决得去上一课的tools/pre-execute(waterfall)那一层。- log-only 事件进不了模型上下文。你在日志里能看见
turn/start,但它不会变成模型能看到的消息。给模型「加新的可见信息」和「写自定义日志事件」是两码事,前者有它单独的接缝,别绕。 - live 与 durable 别串台。
ctx.on('turn/end')永远收不到持久日志里的事件,正解只有一个入口:ctx.on('session/event'),再在回调里用event.type分拣。 - 消息结构以官方
types.ts为准。user/message里的content和assistant/message里的message.content路径不一样、字段也在变(rc 阶段),别死记一个结构,写个防御性的extractText兜底。
六、小结
这一课我们从「消息数组」的成见里爬出来,看清了 dsh 的会话本质是一条 append-only 事件日志;学会了用 session/event 这一个入口观察所有落盘事实,分清 surface 事件和 log-only 事件,再动手写了个把一轮对话炼成带 SHA-256 防伪凭据日报的插件。事件溯源这层打通了,后面不管是写审计、写回放、写跨会话聚合,都只是在这条流水线上接不同出口。
生命不息,折腾不止。下一课我们解决一个现实焦虑:事件日志只会越攒越长,模型上下文迟早装不下——dsh 是怎么在「保住真相」和「压小体积」之间做自动摘要的?明天钻进它的压缩(compaction)机制,看
compaction/start、compaction/summary、compaction/end这一串事件怎么把旧历史「瘦身」又不丢审计轨迹。