会话与分支

Pi 的会话不是普通聊天数组,而是 append-only JSONL 树。这个设计支撑了 resume、fork、tree navigation、branch summary、compaction、label 和扩展自定义状态。

本章涉及的 entry 类型、buildSessionContext() 和 runtime replacement 行号见源码索引

关键源码:

  • packages/coding-agent/src/core/session-manager.ts
  • packages/coding-agent/src/core/agent-session-runtime.ts
  • packages/coding-agent/src/core/messages.ts

JSONL Entry 模型

会话文件第一行是 SessionHeader,后续每行是一个 SessionEntry。每个 entry 都有:

  • id
  • parentId
  • timestamp
  • type

主要 entry 类型:

类型 用途 是否进入 LLM context
message user/assistant/toolResult 等标准消息
model_change 记录模型切换 否,但恢复 session 时用于选择模型
thinking_level_change 记录 thinking level 否,但恢复时用于状态
compaction 保存压缩摘要和 firstKeptEntryId 通过 summary message 进入
branch_summary 保存离开分支的摘要 通过 branch summary message 进入
custom 扩展持久化私有状态
custom_message 扩展注入上下文消息
label 给 entry 加书签/标记
session_info 会话名等元数据

为什么是树

普通聊天数组只能表示一条线。Pi 要支持:

  • 从历史某点 fork。
  • /tree 中切换 leaf。
  • 保留旧分支但在新分支继续工作。
  • 在离开分支时把分支工作总结带入另一边。

因此 SessionManager 维护 leafId,追加 entry 时把 parentId 指向当前 leaf,再把 leaf 前移到新 entry。

flowchart LR A["A: user"] --> B["B: assistant"] B --> C["C: toolResult"] C --> D["D: assistant"] B --> E["E: fork user"] E --> F["F: assistant"]

当前 leaf 决定了发给模型的路径。如果 leaf 在 F,上下文是 A -> B -> E -> F,不会包含 C -> D

buildSessionContext()

buildSessionContext(entries, leafId, byId) 是会话树到模型上下文的关键转换:

  1. 从 leaf 沿 parentId 回溯到 root。
  2. 反转得到当前分支路径。
  3. 扫描路径恢复最新 model 和 thinking level。
  4. 找最后一个 compaction entry。
  5. 如果有 compaction:
  6. 先插入 compaction summary message。
  7. 再插入从 firstKeptEntryId 到 compaction 之前的保留消息。
  8. 再插入 compaction 之后的消息。
  9. 如果没有 compaction,就按路径插入所有可见消息。

这意味着会话文件保留完整历史,但 LLM 看到的是“当前 leaf 的解析视图”。

自定义消息与上下文

packages/coding-agent/src/core/messages.ts 通过 TypeScript declaration merging 扩展 AgentMessage

  • bashExecution: 用户通过 ! 运行 shell 的记录。
  • custom: 扩展注入的消息。
  • branchSummary: 离开分支的摘要。
  • compactionSummary: 历史压缩摘要。

convertToLlm() 决定这些消息如何进入模型上下文:

  • bashExecution 转成 user text,除非 excludeFromContext
  • custom 转成 user message。
  • branchSummarycompactionSummary 用固定前后缀包裹摘要。

这是一种很实用的模式:应用层可以拥有丰富消息类型,但 provider 层只看到标准消息。

Runtime 替换

AgentSessionRuntime 负责当前 session 与 cwd-bound services。切换 session、new session、fork 时,它会:

  1. 触发 session_before_switchsession_before_fork
  2. 触发旧 session 的 session_shutdown
  3. dispose 旧 AgentSession,使旧 extension context 失效。
  4. 用新的 cwd/sessionManager 重新创建 runtime。
  5. 调用 UI/宿主提供的 rebind 回调。

这个设计避免扩展持有旧 session context 后继续写入错误会话。源码里还专门有 stale context 的错误提示。

设计启发

  • 对 agent 会话来说,append-only 树比可变数组更适合审计、分支和恢复。
  • “存储全历史”和“发送给模型的上下文”应该是两个不同概念。
  • 扩展状态分成 customcustom_message 很清晰:一个只给扩展恢复,一个参与 LLM context。
  • 切换 session 时让旧 context 显式失效,可以减少扩展里的悬挂引用问题。