turn / step 与上下文防御
turn / step 事件流全图、session log 唯一真相,以及三个可卸载的上下文防御插件。
M2 讲的是「树怎么长出来」,这一页讲「树跑起来以后发生了什么」。三件事:一次对话在事件层面到底经过哪些环节、为什么整个系统只认一条 append-only 日志、以及三个可以随时卸掉的上下文防御插件。
3.1 turn 与 step:一张事件流全图
先把两个词定死,仓库 glossary 里写得很清楚:
注意「零个」——一个被拒绝的首次认领,仍然会关闭一个持久化的、没花掉任何 step 的 turn,好让日志记下这次尝试。这个细节是整套设计的缩影:宁可日志里多一条「什么都没发生」,也不要日志里缺一条。
一个 turn 的完整事件流
turn/start 认领 next-step 输入 + 一条排队消息 装配 prompt sections + 工具 schemas → agent/pre-step reject | enter(messages) 被拒绝,或首次认领被改写成空 → 关闭 turn,不花 step step/start 把 entered messages 追加为 user/message 从日志投影出模型历史 agent/request → llm/stream → assistant/chunk* → assistant/message tool/call* → tools/pre-execute → tools/execute → tools/post-execute → tool/result* step/end 工具还欠一次请求,或 next-step 输入到了 → 认领 → 下一个 step → agent/turn-stopping turn/end
next() 才委托下去);agent/turn-stopping 是 serial,没有 next()。三个事件域,先选对域再动手
官方 architecture.md 说得直白:事件就是扩展点,选对域是大多数改动的第一个决定。
| 域 | 是什么 | 什么时候用 |
|---|---|---|
| Session 事件 | 追加进日志、并通过 session/event 广播的持久事实 | 这个事实必须活过一次重载 |
Agent 事件(agent/*) | 携带一个活的 Agent:inbox、step、status、request、validation、continuation | 观察或拦截正在进行的工作 |
| Capability 事件 | fs/*、tools/*、telemetry/*——把策略和适配器挂到某个 seam 上 | 你不想 import agent loop |
agent/pre-step 是最重要的那个:它决定模型看见什么。监听器可以改写被认领的消息,也可以直接拒绝它们。M3 后面讲的三个防御插件,两个挂在 tools/*,一个挂在 agent/pre-step 上。
3.2 Session log:唯一真相
整个系统只认一条 append-only 的 SessionEvent 日志。deriveMessages() 从它投影出模型历史;原始的 assistant/chunk 事件被完整保留,用来保证重放和 UI 的保真度。fork、resume、transcript、遥测、持久化,全部派生自这一条流。
这条不变量还有一个很妙的落地:repeat-tool-reminder 那个循环破除器要给模型塞一句提醒,它没有发明新机制,而是把提醒作为一条「插件来源的 user/message」追加进日志——于是提醒既是模型可见的、又标明了来源、还能从日志重建,一个新的 session 事件都不用加。
SessionEventMap 的成员默认是required-on-read:一个不认识这个事件类型的构建会拒绝这份日志,除非该事件在信封里携带了 ignorable: true。只有结构性的格式变化才动 SESSION_FORMAT_VERSION。
而这个版本号现在是 0,仓库明确写着不作任何兼容承诺——见 M10 的风险一节。
3.3 上下文防御三件套
长对话会以三种方式崩坏:历史太长、单条工具输出太大、模型陷在循环里。dsh 对这三件事各有一个插件,而三个都是可以卸掉的普通插件——卸了 agent loop 照跑。这本身就是对 M2 那条断言的一次检验。
compaction —— 历史太长
一个标准的四包 seam:compaction(定义 + 事件词汇,ctx.compaction)、compaction-basic(token 压力检测 + 摘要后端)、compaction-tool-result-pruner(可选的、不用模型的工具结果裁剪)、command-compact(人类可用的 /compact 命令)。
值得注意的是那个 pruner 被单独切出来了:它是model-free 的,纯规则裁剪旧工具结果,不烧一次模型调用。换句话说,「压缩」这件事被拆成了两个成本级别完全不同的动作,部署方可以只要便宜的那个。
spill —— 单条工具输出太大
三个包:spill 定义存储(ctx.spillStore),spill-local 把溢出文本写进 session 作用域的本地文件,spill-policy 挂在 ctx.tools 上执行「执行后」策略。
做法是:把超大的工具输出落盘,然后把内联结果换成一个有界的预览 + 一个取回定位符。模型看到的不是被粗暴截断的一半内容,而是一段摘要加一个「你可以去拿全文」的句柄。这个设计的关键在于它把决定权留给了模型——需不需要全文,模型自己判断。
guard —— 模型陷在循环里
repeat-tool-reminder 的自我描述里,第一句就是「它不是一个模型可见的工具」。它做的事只有一件:盯住每个 agent 的工具调用流,数连续调用同一个工具且参数规范化后完全相同的次数,在配置的长度上注入一句逐级升级的提醒。
几个细节能看出这个插件被认真想过:
- 链的 key 是「工具名 + 规范化参数」——规范化 = 深度键排序后
JSON.stringify,所以只是属性顺序不同的参数算同一次。 - 不被跟踪的调用对链是透明的。被
exclude掉的调用既不增加计数也不重置它——于是grep X → todo_write → grep X在todo_write被排除时仍然算两次连续的grep X。README 里给了理由:「记账类工具穿插进一个循环,不能把这个循环洗白。」 - 被拒绝的调用照样计数。检测挂在
tools/post-execute上,而这个事件对被pre-execute拒掉的调用也会跑——「一个在反复捶一个被拒调用的模型,正是最该被打断的循环」。 - 按 agent 分链。
WeakMap<Agent, Chain>,一个 agent 的重复永远不会触发另一个的提醒;对象生命周期天然回收,连销毁监听器都不用写。 - 只在内存里。从持久化 resume 出来的 session 拿到的是一条新链——因为它是启发式的提示,不是被记录的不变量。
另一个 guard 包 timeout-policy 更简单:给每次工具调用装一个部署级的截止时间。它注册的就是一个普通的 tools/execute 监听器。README 里点明了这两个包的定位——「guard 是核心服务和扩展点的自足消费者,不是一个可替换的能力。」这句区分很重要:不是所有插件都必须是 seam,把不该抽象的东西抽象掉,同样是错误。