长会话总会撞上下文上限,做 agent 的都逃不掉压缩这个课题。多数做法是「改历史」——把旧消息替换成摘要。dsh 的做法不太一样:压缩产生的摘要不是历史的一部分,压缩这个动作本身才进日志。
三个 log-only 事件
packages/compaction/ 定义了三个只写日志的事件:compaction/start、compaction/summary、compaction/end。子系统文档的原话:它们「record the lock, summary, selected range, shadowed event seqs, token count, and model call without joining the surface」——记录了锁、摘要、选中区间、被遮蔽的事件序号、token 数和模型调用,但不进入模型表面。
「不进入表面」是关键。摘要不会直接出现在模型看到的消息里,它搭一条便车:一条带 surfaceOp: { op: 'replace', start, end } 的 user/message,这条消息是压缩带来的唯一表面变更。换句话说,模型历史的重建规则没变,变的只是某段区间被一条摘要消息替换了。
日志锁:崩溃变成可检测,而不是假完成
compaction/start 和 compaction/end 这对事件实际上是一把日志级的锁。释放锁放在最后一步,文档解释了为什么:
Releasing the lock last turns a crash mid-operation into a detectable orphaned lock (a
compaction/startwith no matchingcompaction/end).
压缩做到一半进程挂了,日志里会留下一把没有配对 end 的孤儿锁——下次能检测出来,而不是误以为压缩完成了。这是「崩溃要留下可检测的痕迹」这条原则的又一次出现,和第二篇里「崩溃的 turn 关闭而不截断」是同一族设计。
两个触发时机
触发类型定义为 CompactionTrigger = 'pressure' | 'context-overflow',对应两条路径:
- 压力压缩:挂在
agent/pre-step这条 waterfall 上,在请求派生之前跑——模型还没看到消息,先把历史瘦下来; - 溢出恢复:挂在
agent/request-error上,请求真的因为超长失败时兜底。
服务暴露三个动词:compactIfNeeded(agent, trigger)(自动)、compactNow()(空闲期主动缩减,此时 turn: null 单独成档)、compactRegion()(显式指定区间)。人类侧的入口是 /compact 命令。
摘要之前的二级接缝
压缩之前还有一道工序:ctx.toolResultPruner。超大工具输出先被裁剪,再谈整体摘要,每次替换都报告 charsBefore / charsAfter。选区边界也有讲究——保持工具调用和结果成对(压缩前后都校验配对平衡),所以一个超大 turn 里,只有早期已经完成的 step 会被选中。
小结
回头看,这个设计之所以干净,是因为它没给压缩开特权:压缩不修改历史,只追加自己的痕迹,表面变更走标准的消息事件。第二篇说的「模型可见 = 已记录」在这里升级成了「连压缩这种对可见内容的改写,自己也被记录」。不变量守住了自己。