上一篇讲了 dsh 的插件架构。这篇看它的心脏:一条用户输入进来之后,「模型思考 → 调工具 → 再思考」的循环怎么被驱动,以及每一步为什么都要留痕。
事件有两个平面
刚读代码时我踩过一个坑:turn/start、agent/pre-step 这些名字看起来是同一类事件,其实分属两个平面。
| 平面 | 例子 | 写入方式 | 语义 |
|---|---|---|---|
| 会话日志事件 | turn/start、step/start、tool/call、step/end、turn/end | session.append() | 已发生的事,持久、只追加 |
| Cordis 分发事件 | agent/pre-step、agent/turn-stopping、tools/execute | dispatch.waterfall / serial | 将发生的事,可被拦截、改写 |
日志事件是「发生了什么」,分发事件是「要发生了,谁想拦一下」。前者写在 SessionEventMap(merge 可扩展,定义在 packages/core/session/src/types.ts),后者带 @mode 标注,是事件的公共契约的一部分。
session log 是唯一事实来源
dsh-session 提供的 Session 就是一条只追加的类型化事件日志。关键点:LLM 的消息历史不单独存,而是从日志派生出来的(deriveMessages())。packages/core/session/README.md 的说法是「the single source of truth every model-visible fact flows through」。
配套的几个细节都透着同一种脾气:
SESSION_FORMAT_VERSION至今是 0,明确不做兼容承诺——预发布期地基优先;- 事件词汇表是 required-on-read:不认识某个事件类型的构建会直接拒绝读这份日志,宁可报错不猜;
- 持久化侧的规矩:「a crashed turn is closed, not truncated」——崩溃的 turn 追加一条关闭记录,绝不改写已落盘的事件。
一个 turn 的完整旅程
turn/start ──(log)
认领输入 → 组装 system prompt + 工具 schema(每步一次)
agent/pre-step ──(waterfall,可拒绝/改写)
step/start ──(log)
LLM 请求(请求体深冻结,不再改写)
tool/call ──(log)
tools/pre-execute → tools/execute → tools/post-execute(皆 waterfall)
tools/result ──(emit)
step/end ──(log)
↺ 有需要就再来一步
agent/turn-stopping ──(serial)
turn/end ──(log)
所有分发点都在 packages/core/agent-loop/ 里,它是「the harness’s only concrete loop」。比如 agent.ts 里 session.append('turn/start', { turn })、dispatch.waterfall('agent/pre-step', ...)、dispatch.serial('agent/turn-stopping', ...) 各自就一行,管线骨架一目了然。
waterfall:能拦、能改,但必须调 next()
agent/pre-step 是最典型的拦截点。监听器拿到消息和一个 next(),返回一个决策:
// packages/core/agent/src/runtime-types.ts
export type PreStepDecision =
| { kind: 'reject' }
| { kind: 'enter'; messages: UserMessage[]; startsRequestSeries?: true }
返回 reject,这一步被否掉,整个 turn 以 { kind: 'blocked' } 收尾;返回 enter 并换上新的 messages,等于改写进入模型的内容;调 next() 则委托给链的下一层,最内层的默认行为由循环自己提供。
看一个真实插件的用法,packages/context/time-context/ 用 prepend: true 挂在 agent/pre-step 上:先 await next() 拿到下游决策,如果是 reject 或者已中止就原样放行,否则往 messages 里追加一条自己生成的时间上下文消息再返回。
仓库规约对 waterfall 有条硬规则:「Waterfall listeners MUST call next() to delegate; returning without it short-circuits the chain.」不调 next 就是短路——拒绝和改写都要做得明明白白,测试里三种情况(透传、直接拒绝、直接替换)各有用例。
Model-visible ⟺ logged
把这些机制串起来的是根目录 AGENTS.md 里的一条不变量:
Model-visible ⟺ logged: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.
凡是会进入模型请求的内容,必须能从 session log 重建;想引入一种新的模型可见输入,先加一种 session 事件。上面 time-context 的例子正是这条规则的实例:插件往 messages 里塞了东西,就必须作为日志事件落盘。
packages/llm/llm/README.md 还补了后半句:「loop-built requests are deep-frozen and never rewritten」——循环组装好的请求深冻结,谁也不许事后改写。
这条不变量换来的东西很实在:回放、审计、测试全都可以只拿一条日志做文章。后面讲质量门禁那篇里「无 API key 的录制回放测试」,地基就是这一条。
下一篇讲 dsh 的另一个结构概念——capability seam,shell、文件系统、沙箱这些危险能力是怎么被做成可插拔的。