投影(Projection):同一份日志长出不同状态
Session支持多种projection从同一份event log派生状态。requestHeader()增量fold,requestContext()同理,RuntimeContextProjection检查最后一个owned user/message去重,applyGoalProjection轻量last-wins。所有投影结果deepFreeze防consumer修改。
你每次调用 session.requestHeader(),它不是从头遍历所有 events 算出来的。它只看上次 fold 到哪里了,从那个位置往后处理新事件。这就是投影(Projection)模式:同一份不可变日志,派生多种只读状态,每种状态有自己的增量缓存。
Surface 是投影(给模型看的 messages)。Header 是投影。Context 是投影。Goal 是投影。Runtime context 也是投影。它们都从同一份 log 长出来,但各有各的缓存策略和 fold 逻辑。
如果前两章回答的是“事实源是什么”和“模型历史怎么长出来”,这一章回答的就是另一件事:同一份事实源怎么长出多份状态视图,而且每份视图都只增量处理新事件。
别把 Projection 想成“把日志从头重算一遍”。在 Harness 里,它更像一组各自带缓存的折叠器:Header 关心配置快照,Context 关心路由元数据,Goal 关心 last-wins 状态,Runtime context 关心注入去重。它们共用一份日志,但折叠规则不是一套。
先把这个模式看明白,再去看 requestHeader()、requestContext()、RuntimeContextProjection。不然你很容易陷在某个 fold 细节里,读到后面不知道它到底在解决什么。
requestHeader:增量 fold + 规范化比较
每次 step 准备发请求前,需要知道”当前生效的 request header 是什么”——model、temperature、maxTokens 这些配置。但 header 不是存在一个可变对象里的。它通过 fold 日志里的 request/header 事件得到。
Session 维护两个字段:
private headerFold: EpochHeader | undefined
private headerFoldSeq = 0
调用 requestHeader() 时:
- 如果
headerFoldSeq >= this.log.length,缓存命中,直接返回 `headerFold - 否则 slice 出新 events(
this.log.slice(this.headerFoldSeq)),从当前 headerFold 开始 fold - fold 结果 deepFreeze,更新 headerFoldSeq 到 log.length
- 返回新的 headerFold
这里有个关键细节:不是简单的 latest-wins。foldRequestHeader 做规范化处理(canonicalHeader),然后用 headerEquals 做深度比较。如果新 fold 出来的 header 和旧的语义等价,返回旧引用。这意味着调用方可以用 === 或 Object.is 来判断 header 是否真的变了——没变就不需要重建请求配置。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "headerFold" "$repo/packages/core/session/src/index.ts"
grep -n "foldRequestHeader" "$repo/packages/core/session/src/index.ts"
requestContext() 更简单——它也是增量 fold,但逻辑就是展开运算符合并对象:遇到 request/context 事件就 { ...event.data } 覆盖。Context 没有 header 那么复杂的规范化需求,直接 latest-wins。
RuntimeContextProjection:反向扫描去重
RuntimeContext 是一个外部投影,不在 Session 类里面,在 agent-loop 包里。它解决一个具体问题:动态上下文(比如当前打开的文件、AGENTS.md 内容、cwd 状态)每步都可能变,但如果内容没变,你不想每步都往 session 里 append 一个相同的 user/message——那会污染历史、浪费 token。
RuntimeContextProjection 的策略很聪明:
构造时(session 已存在): 从日志尾部反向扫描,找到最后一个 isOwned(event.data) 的 user/message(owned 表示是 runtime context 自己注入的,不是用户发的)。记住它的 seq 和内容 text。反向扫描是因为你要的是”最后一个”,从后往前找第一个就停。
运行时(监听 session/event):
- 如果是新的 owned user/message,更新 retained 为
{seq, text} - 如果是 replace 事件且 sourceEventSeqs 包含当前 retained 的 seq(说明我们的 context 消息被 compaction 替换掉了),把 retained 设为 null——需要重新注入
project() 方法: 传入 current(当前渲染出的完整 context 文本),和 retained 比较:
- 没有 retained 且 current 为空 → undefined(不需要注入)
- retained.text === snapshot(snapshot 是 current 或 CLEARED 标记)→ undefined(没变,不重复注入)
- 否则返回一个新的 UserMessage,append 到 session
这就是”去重注入”。Runtime context 不会每步都发消息,只在内容真正变化时发。
flowchart TD
A["project(current, sections)"] --> B{"retained === undefined\n且 current 为空?"}
B -->|是| Z["return undefined\n无需注入"]
B -->|否| C["snapshot = current || CLEARED"]
C --> D{"retained?.text === snapshot?"}
D -->|是| Z
D -->|否| E["return new UserMessage\nappend到session"]
style Z fill:#666,color:#fff
style E fill:#1a3a5c,stroke:#d4af37,color:#fff
GoalProjection:轻量 last-wins + Object.is 门控
Goal 投影是最轻量的。applyGoalProjection(state, event) 是一个纯函数:
- 如果 event.type 不是 ‘goal/change’,直接返回原 state(Object.is 保证引用不变)
- 如果是 ‘goal/change’,解码 payload
- operation === ‘clear’ → null
- 否则返回新的 goal state 对象
对非 goal 事件直接返回同一个 state 引用,这让消费方可以用引用相等来快速判断”goal 变了没有”。如果每次都返回新对象,即使 goal 没变,消费方也会以为状态变了做不必要的重渲染/重计算。Object.is 门控就是防止这种情况。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "if (event.type !== 'goal/change') return state" "$repo/packages/goal/goal/src/index.ts"
为什么所有投影结果都 deepFreeze
你注意到一个 pattern 了吗?headerFold、contextFold、deriveMessages 返回的 Message 对象、goal state——全部 deepFreeze。
为什么?因为投影结果是 session 内部状态的引用暴露。如果消费方拿到 session.requestHeader() 返回的对象,随手改了 header.temperature = 0,就直接改了 session 内部的缓存。下一次 fold 看到 headerFold 存在而且 headerFoldSeq 到尾了,直接返回这个被篡改过的对象。投影缓存就被污染了。
deepFreeze 让这种篡改在严格模式下直接抛 TypeError。这不是”建议不要修改”,是运行时强制不可变。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "deepFreeze" "$repo/packages/core/session/src/index.ts" | head -10
投影缓存的通用模式
所有投影缓存遵循同一个模式:
| 投影 | 缓存字段 | 增量位置 | 重建触发 | 相等性策略 |
|---|---|---|---|---|
| events | eventsSnapshot | 无(整个log) | append后失效 | Object.freeze浅拷贝 |
| surface | nodes/replaceGeneration | _lastProcessedSeq | replace时重建 | 增量append |
| deriveMessages | derived/derivedNodes/derivedGeneration | derivedNodes | replaceGeneration变化时重建 | 增量投影新节点 |
| requestHeader | headerFold/headerFoldSeq | headerFoldSeq | 新events | headerEquals深度比较 |
| requestContext | contextFold/contextFoldSeq | contextFoldSeq | 新events | 对象展开覆盖 |
| goal | 外部fold | 外部维护 | goal/change事件 | Object.is引用门控 |
| runtimeContext | retained{seq,text} | 监听session/event | replace/retained被覆盖 | text字符串=== |
核心思想:缓存计算结果,记录处理到哪里了,下次从断点继续。结构变化(replace)时从头来,追加变化时增量走。返回冻结结果防篡改。
容易踩的坑
坑一:修改 requestHeader() 返回的对象。 它是 deepFrozen 的,严格模式下直接 TypeError。非严格模式下静默失败但下次 fold 可能覆盖你的修改。不要改,要改就自己拷贝一份再改。
坑二:每步都注入相同的 runtime context。 RuntimeContextProjection 的存在就是防止这个。如果你自己在 agent-loop 外面写逻辑注入 context,一定要比较内容是否变化,否则每步都加一条一模一样的 context 消息,历史越来越长,token 浪费严重。
坑三:用 === 比较两个语义等价但引用不同的 header。 headerEquals 做深度比较。如果你自己 session.requestHeader() 拿到 header,和之前存的 oldHeader 用 ===` 比较,可能不相等但语义一样。用 headerEquals 或者依赖 Session 内部的引用相等(在同一个 session 实例上连续调用 requestHeader(),没变时返回同一个引用)。
坑四:投影不是 O(1)。 第一次调用 requestHeader() 或发生 replace 后调用 deriveMessages(),都是 O(n) 重建。不要在热路径上无意义地重复调用。但正常增量路径下,每个事件只被 fold 一次,摊还 O(1)。
这章讲完了,还没讲什么
你知道了内存里怎么从日志投影出各种状态。但日志不能只呆在内存里——进程退出就没了。怎么持久化到磁盘?怎么保证写文件时崩溃不会损坏数据?崩溃后重启怎么修复未闭合的 turn?
下一章讲 JSONL 原子写和崩溃恢复。