青雲的博客
深入浅出 Pi 第四部:会话、快照与 Harness 第 22 章

Session 保存的是树,不是一串消息

沿 SessionTreeEntry、parentId、leaf entry 与 buildContext 拆开追加顺序、分支拓扑、active pointer 和模型可见投影。

源码版本
v0.83.0
验证日期
Commit
845d6ff1f6643aba440341cce877ce1c43ebbc39

如果 Session 只是一串消息,回到旧节点就必须删掉尾部,或者复制一份新会话。Pi 两件事都没做。它让存储保持 append-only,再用 parentId 表示每个 entry 接在哪个节点之后。文件中的行仍按发生时间向后追加,模型上下文却只取 active leaf 所在的祖先路径。

这个设计同时保留两类事实:旧分支做过什么,当前分支又选中了什么。它也解释了为什么上一章必须在保存点控制写入顺序:普通 append 总是把当前 leaf 作为 parent,并把新 entry 变成下一 leaf;插入位置一旦错了,改变的不只是展示顺序,而是树拓扑。

Entry 比 Message 多得多

所有 SessionTreeEntry 都有 typeidparentId 与 ISO timestamp。联合类型除 message 外,还包含 thinking level、model、active tools、compaction、branch summary、custom、custom message、label、session info 与 leaf。会话树不只保存聊天正文,也保存沿分支生效的运行配置、摘要与导航记录。

Session.appendMessage() 的实现展示了普通追加规则:storage 先生成 id,parentId 读取当前 leaf,时间在追加时产生,然后 appendEntry()。模型变更、thinking level、active tools、compaction 与 custom entry 都复用相同规则。于是配置也属于一条分支;从更早节点另开分支,不必继承原分支后面才发生的模型变更。

可以把一段会话写成这组 JSONL。行顺序是时间,箭头由 parentId 决定:

1  {type:"message", id:"u1", parentId:null, ...}
2  {type:"message", id:"a1", parentId:"u1", ...}
3  {type:"message", id:"u2", parentId:"a1", ...}
4  {type:"message", id:"a2", parentId:"u2", ...}
5  {type:"leaf",    id:"nav1", parentId:"a2", targetId:"u1", ...}
6  {type:"message", id:"a3", parentId:"u1", ...}

按文件顺序读,u2/a2 仍然存在;按当前 leaf a3 回溯,active branch 只有 u1/a3。第 5 行记录了一次“从 a2 移到 u1”的动作,它自身不成为对话的 active leaf。storage 看到 leaf entry 时,把 targetId 而不是 leaf entry 自己的 id 设为 pointer。

Leaf 是指针,也是一条可重放记录

Session.moveTo() 先校验目标存在,然后调用 storage 的 setLeafId(entryId)。JSONL backend 并不只改内存变量;它构造一个 type: "leaf" 的 entry,parent 指向移动前的 current leaf,target 指向移动后的节点,先 append 文件,再更新内存索引与 current leaf。重新打开文件时,loader 顺序扫描所有 entry,普通 entry 让 leaf 变成自己的 id,leaf entry 则让 leaf 变成 targetId。

这比只在 header 覆盖一个 activeLeafId 多写一行,却保留了导航发生的先后。内存 backend 遵循同一合同;SQLite backend 可以额外维护 materialized branch,但对上仍实现相同 SessionStorage 接口。

moveTo() 若收到 summary,会在移动 pointer 后追加 branch_summary,其 parent 正是目标 entry。这样旧分支摘要成为新分支的第一个可见节点,新的 current leaf 也随之变成 summary entry。没有 summary 时,当前 leaf 就停在目标本身。

模型读到的是 active path 的投影

Session.getEntries() 返回 append log;Session.getBranch() 则从显式 fromId 或 current leaf 调用 getPathToRootOrCompaction()buildContextEntries() 在这条 path 上先执行默认 compaction transform,再执行应用提供的 entry transforms。buildContext() 进一步将 entry 映射为 AgentMessage[],同时派生 thinking level、model 与 active tools。

默认投影也不是“entry.payload 原样交给模型”。message 直接成为 AgentMessage;custom message 转成 role: custom;compaction 与 branch summary 各自变成摘要消息;普通 custom entry 默认返回空数组,只有注册了对应 entryProjectors[customType] 才能贡献消息。label、session info、leaf 和几种配置 entry 都不直接出现在 message 列表中。

因此要区分三个集合:

集合来源包含旧分支吗直接模型可见吗
append loggetEntries()包含
active pathgetBranch()只含当前祖先路径;遇 compaction 还有边界
context messagesbuildContext()来自 active path 的选择与投影是,之后还要经过 convertToLlm

UI 可以用 append log 画整棵树,provider request 却不应把旧分支一起带上。反过来,某条 entry 没进入 provider context,也不代表它没有持久价值:label 用于导航提示,session info 用于列表名称,leaf entry 用于重建 pointer。

用同一套测试观察两种 backend

session.test.ts 把相同 suite 同时跑在 in-memory 与 JSONL storage 上。测试先追加 user1 -> assistant2,再把 leaf 移回 user1,追加 branched。重新用同一 storage 创建 Session 后,context roles 仍只有 user, assistant,并且 label 与 session name 可读。JSONL suite 还检查文件确实含 header 和 leaf entry。

运行时用 Harness 专属配置:

repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
cd "$repo"

npm run test:harness --workspace @earendil-works/pi-agent-core -- \
  test/harness/session.test.ts \
  -t 'persists leaf changes and appended entries via storage'

这条测试证明 backend 合同和 branch context,不证明并发进程可以共同写一份 JSONL。SessionStorage 没有 file lock 或 compare-and-swap 参数;单 writer 仍是宿主责任。

树结构现在已经清楚,下一步才适合谈“缩短上下文”和“回到旧分支”。这两个操作都会改变模型随后看见的 active path,但方式完全不同:compaction 在当前分支尾部增加一个摘要屏障;navigation 移动 leaf,并可把离开的分支压成 branch summary。第 23 章把两条路径分开追。