agent 产品里常见一批「状态」:计划模式开了没、待办列表写了啥、长期目标推进到哪。多数实现里它们是运行时内存对象或独立存储。dsh 给出的答案是同一个词:logged state——状态就是日志事件折叠出来的结果。
plan、todo、goal 三个包,正好是这个理念的三种深度。
plan:一个布尔,折叠出来
计划模式的状态就是一个 log-only 事件:
// packages/plan/plan-mode/src/index.ts
'plan/mode': { active: boolean }
export function foldPlanMode(events, end = events.length): boolean {
// 遇到 plan/mode 事件就更新 active,扫完即当前状态
}
没有活体镜像,当前状态 = 把日志扫一遍。文档明确它「durable and replayable, never in the model transcript」——持久、可重放,但不出现在模型对话里。
有个细节暴露了这套设计的严谨:会话事件必须发生在 turn 内,可用户在 turn 外点了切换怎么办?答案是先记为 pending,等下一个被接受的 agent/pre-step 监听器(turn 内)把它追写进日志;只有当状态真的变了,才再补一条 user/message 提醒模型。状态变更不抢跑,也不静默。
todo:整表快照,越简单越对
todo_write 工具每次调用都整表替换,追加一条 todo/write: { todos } 快照事件。条目字段少得惊人:content 加一个状态(pending / in_progress / completed)。文档特意点明:「No id, priority, or activeForm — the list is replaced wholesale on every write.」
为什么不要 id?因为每次都是全量替换,谁也不需要做增量定位,状态天然是「最近一次快照」。包还自带一个 invariant 伴生:校验每条 todo/write 都发生在开放 turn 内、且无重复内容。
goal:带修订号的快照状态机
到了 goal,复杂度和深度都上来了:全快照的 goal/change 事件,外加 GoalRef { id, revision } 做比较并设(compare-and-set),防止并发下旧的写入覆盖新的。状态机的相位 GoalPhase = 'active' | 'paused' | 'blocked' | 'complete' 是持久相位,进日志;而「激活与否」(能不能继续下一轮)是进程本地的,故意不持久化。组内还配了 goal-round-driver 驱动、tool-goal 工具和 command-goal 命令。
为什么值得这么干
把状态存成日志事件,白捡三样东西:
- 持久化免费。状态跟着会话日志落盘,第六篇的持久化层不用为它做任何特判。
- resume / fork / 压缩后自动恢复。状态是日志的纯函数,换任何载体重算一遍就回来了,没有「内存状态没同步」这类 bug。
- 可回放可审计。计划什么时候开的、待办怎么变的,翻日志即可,时间线完整。
从 plan 的一个布尔,到 todo 的整表快照,再到 goal 的带修订号状态机——同一个模式按复杂度递增演进了三次。这不是三个孤立的包,是一条被验证过可以伸缩的设计线。