05 Session 学习记录
本节目标
本节目标是理解 Pi 的产品级会话系统:多轮历史如何保存、如何恢复、如何在历史中切分支,以及上下文变长时 compaction 如何介入。
重点问题:
Agent、AgentSession、SessionManager各自管什么。- 为什么 session 文件要保存
user、assistant、toolResult。 - JSONL session 为什么不是普通数组,而是 append-only tree。
/tree如何回到旧消息继续分支。/tree、/fork、/clone、/new的区别。- 为什么切分支后要
buildSessionContext()并覆盖agent.state.messages。 - compaction 为什么追加 entry,而不是删除旧历史。
我们怎么学的
- 先看
packages/agent/src/agent.ts,确认Agent是运行时状态控制器。 - 再看
packages/coding-agent/src/core/session-manager.ts,确认 session 文件是 append-only JSONL tree。 - 看
AgentSession._handleAgentEvent(),理解消息何时持久化。 - 通过
/tree实际观察 session tree,理解切回旧 entry 后继续分支。 - 看
navigateTree(),理解leafId改变后如何重建 agent context。 - 最后看
compact()、_checkCompaction()、buildSessionContext()中 compaction 的处理。
三层职责
05 的第一条主线是三层职责:
Agent = 当前运行时状态
SessionManager = 持久化历史和分支树
AgentSession = 产品层会话控制器,把两者接起来
Agent
Agent 是内存里的运行控制器。
它持有:
- 当前上下文
messages/tools/model。 - 当前是否在跑
activeRun。 - 运行中插队消息
steering/followUp。 - 事件监听
listeners。 - abort、continue、prompt 等运行控制。
真正执行模型和工具循环时,Agent 会把这些状态打包成 context snapshot 和 loop config,交给 runAgentLoop()。
SessionManager
SessionManager 管 session 文件和历史树。
它持有:
- 当前 session id/file/dir。
fileEntries:JSONL 中的完整历史。byId:entry id 索引。leafId:当前分支末端。- label、session name、parent session 等元数据。
它提供:
appendMessage()。appendCompaction()。branch()/resetLeaf()/branchWithSummary()。getBranch()。buildSessionContext()。
AgentSession
AgentSession 是产品层会话控制器。
它负责把 agent 事件、session 持久化、extensions、compaction、UI/RPC 行为串起来。
关键点是 message_end 时持久化:
event.type === "message_end"
user / assistant / toolResult -> appendMessage()
custom -> appendCustomMessageEntry()
所以 session 不是保存每一个运行时事件,而是保存可恢复上下文需要的最终消息和 entry。
为什么保存 toolResult
session 必须保存三类核心消息:
user 用户意图
assistant 模型行为,包括文本和 tool call
toolResult 外部世界返回给模型的信息
如果只保存 user 和 assistant,恢复后会缺少工具观察结果。
例如:
user: 看一下 a.ts
assistant: toolCall read(a.ts)
toolResult: a.ts 内容...
assistant: 根据文件内容回答
如果 toolResult 没保存,resume 后模型只知道它曾经调用了 read(a.ts),但不知道工具返回了什么。
更严重的是,provider 协议通常要求 assistant tool call 和 tool result 稳定配对:
assistant.toolCallId
toolResult.toolCallId
缺少 tool result 不只是上下文少了内容,还可能让 provider payload 不合法。
一句话:
assistant 负责“模型决定调用什么工具”;
toolResult 负责“工具实际返回了什么”。
append-only session tree
session 是 append-only tree,不是简单数组。
核心字段:
id 当前 entry 自己的 id
parentId 它从哪个 entry 接出来
leafId 当前会话所在的末端
三者不要混在一起:
fileEntries
= JSONL 文件里的完整 append-only entry 列表
parentId
= 单条 entry 指向上一条 entry 的边
leafId
= 当前选中的分支末端,不代表文件里只有这一条路径
每次追加消息时:
entry.parentId = this.leafId
append entry
this.leafId = entry.id
线性对话是:
A -> B -> C -> D
leafId = D
如果回到 B 分支,branch(B) 不删除 C/D,只是把 leafId 移到 B。
下一条新消息 E 会变成:
A -> B -> C -> D
\
E
所以:
fileEntries = 所有历史 entry 的追加日志
leafId = 当前选中的对话路径末端
getBranch() = 从 leafId 往 parentId 反向走回 root
buildSessionContext() = 把当前 branch 转成 LLM messages
Mermaid 源码:
flowchart TD
A[user A] --> B[assistant B]
B --> C[user C]
C --> D[assistant D]
B --> E[user E]
E --> F[assistant F]
/tree 怎么切回旧历史
交互模式里用:
/tree
流程:
/tree
-> showTreeSelector()
-> session.navigateTree(entryId)
-> sessionManager.branch(newLeafId)
-> sessionManager.buildSessionContext()
-> agent.state.messages = sessionContext.messages
-> UI 重新 renderInitialMessages()
如果选中的是 assistant message:
leaf = selected assistant
新问题会接在该 assistant 后面。
如果选中的是 user message:
leaf = user message 的 parent
editorText = 这条 user message 的文本
也就是 Pi 会把这条 user prompt 放回输入框,让你可以修改后重跑。
例如:
US:A
PI:B
US:C
PI:D
US:E
PI:F
选中 US:C 后,会回到 PI:B 后面,并把 C 放回编辑器。
差异可以这样记:
选 assistant
= 从这个回答后面继续
选 user
= 回到这条问题之前,把原问题放回编辑器,修改后重跑
Mermaid 源码:
flowchart TD
B[assistant B] --> C[user C]
C --> D[assistant D]
B --> E[editorText = C, leafId = B]
E --> R[修改后重新 prompt]
session tree 手动画图练习
题目:
起点:
A -> B -> C -> D
leafId = D
操作:
/tree 选中 B
输入 E
答案:
A -> B -> C -> D
\
E
leafId = E
fileEntries = A/B/C/D/E 全部保留
检查点:
E.parentId = B,因为输入 E 前已经把leafId移到 B。- C/D 没有删除,因为
fileEntries是完整事实历史。 - 当前模型上下文不是
fileEntries全量,而是从leafId回溯出来的 branch。
/tree 和 /fork /clone /new
可以先记成两类:
当前 session 文件内导航
创建或切换 session 文件
/tree
同一个 JSONL 文件里切分支
不删除旧 entry
只移动 leafId
重建 agent.state.messages
/fork
从某个 user message 复制当前路径
创建一个新的 session 文件
/clone
从当前 leaf 复制当前路径
创建一个新的 session 文件
/new
创建新的空 session
为什么 /tree 后必须 rebuild context
Pi 里有两份状态:
SessionManager.leafId
= 当前历史路径指针
agent.state.messages
= 下一次 prompt 真正发给模型的内存上下文
/tree 只改 leafId 还不够。
如果不重新:
buildSessionContext()
agent.state.messages = sessionContext.messages
就会出现错位:
sessionManager.leafId = 三轮前
agent.state.messages = 仍然是五轮完整对话
下一次新问题持久化会接到新分支,但模型推理却基于旧分支。
这会导致:
文件树上:从 B 分支
模型脑子里:还以为在 E 后继续
所以 /tree 后必须做双同步:
SessionManager 同步当前 branch
Agent 同步当前 LLM context
UI 同步当前显示
compaction
Compaction 的核心不是删除历史,而是:
在 session tree 上追加一个 compaction entry
然后 buildSessionContext() 改变给模型看的 messages
手动 /compact 主线:
compact()
-> abort 当前 agent 操作
-> getBranch()
-> prepareCompaction()
-> 生成 summary
-> appendCompaction(summary, firstKeptEntryId, tokensBefore)
-> buildSessionContext()
-> agent.state.messages = sessionContext.messages
buildSessionContext() 看到 compaction 后,会这样构造 messages:
1. 先放 compaction summary message
2. 再放 firstKeptEntryId 之后、compaction 之前的 kept messages
3. 再放 compaction 之后的新 messages
所以压缩后,模型看到的是:
[compaction summary]
[最近保留的一段原始消息]
[压缩之后的新消息]
原始 session entry 仍然保存在 JSONL 文件里。
Mermaid 源码:
flowchart LR
H[完整历史 entries] --> C[append compaction entry]
C --> V[buildSessionContext]
V --> M[模型看到 summary + kept messages + new messages]
H --> K[原始 JSONL 历史仍保留]
自动 compaction
自动 compaction 有两个触发:
overflow
= 模型返回上下文溢出错误
-> compact
-> 自动 retry
threshold
= 上下文快满了
-> compact
-> 不自动 retry,等用户继续
threshold 判断的核心是:
contextTokens > contextWindow - settings.reserveTokens
也就是给下一次模型调用预留一部分 tokens。
为什么 compaction 不删除旧 entries
因为 session 是 append-only 历史树,compaction 只是改变“发给模型的上下文视图”,不应该破坏原始历史。
直接删除旧 entries 会带来问题:
- 分支会坏,其他 branch 可能还引用旧 entry。
- 压缩摘要是有损的,删除原始记录会影响恢复、审计、debug。
- compaction 是上下文策略,不是存储策略。
- append-only 更安全,不需要重写 JSONL 旧行。
- 支持多次 compaction,每次只是新增一个上下文边界。
可以分清三种历史:
fileEntries
= 完整事实历史,append-only
branch path
= 当前 leaf 对应的路径
Context.messages
= 当前这次要发给模型的压缩视图
compaction 只影响第三个,最多通过 compaction entry 标记第二个;不破坏第一个。
和 Codex auto compact 的对照
我们补充看了 Codex 的 auto compact 资料和源码:
- 页面资料:
https://auto-compact-explainer.pages.dev/ - Codex 源码:
openai/codex里的codex-rs/core/src/compact.rs - Codex 远端压缩源码:
codex-rs/core/src/compact_remote.rs - Codex 官方 manual:
/compact用于总结可见对话、释放 tokens;长任务中 Codex 也可能自动 compact。
从公开源码看,Codex 有两条 compact 路径:
inline/local compact
= 用普通模型请求生成摘要
remote compact
= 通过支持 remote compaction 的 provider 调专门 compact endpoint
Codex inline compact
inline compact 的思路接近传统总结:
history
-> compact prompt
-> 普通 Responses streaming 请求
-> 生成 summary
-> build_compacted_history()
-> replace_compacted_history()
如果 compact 请求本身超过上下文,源码里会从历史开头移除最旧 item 后重试:
ContextWindowExceeded
-> history.remove_first_item()
-> retry
它的结果更接近:
最近真实用户消息 + SUMMARY_PREFIX 摘要
Codex remote compact
remote compact 更特殊。它不是简单返回一段 summary text,而是返回一份新的 replacement transcript。
流程可以理解成:
history + tools + base instructions + personality + reasoning settings
-> model_client.compact_conversation_history(...)
-> 服务端返回 Vec<ResponseItem>
-> process_compacted_history()
-> replace_compacted_history()
本地客户端还会过滤 remote 输出:
- 丢掉 stale/duplicated developer messages。
- 丢掉非真实 user wrapper。
- 丢掉推理、工具调用、工具输出等噪声 item。
- 保留真实 user message、assistant message、compaction/context compaction item。
所以 remote compact 的产物不是:
一段摘要字符串
而是:
压缩后的结构化 history / replacement transcript
这说明 Codex 的 remote compact 背后服务端策略是不透明的:本地源码能看到调用、过滤、安装逻辑,但看不到服务端具体模型、prompt 和摘要算法。
触发与阶段
auto-compact-explainer 页面里总结的关键点:
默认触发:context window 的 90%
触发阶段:pre-turn + mid-turn
执行路径:本地摘要或远端 compact
最终效果:替换 history 并重算 token
其中 model_auto_compact_token_limit 可以控制触发阈值,但仍会被 context window 的 90% 上限限制。
mid-turn compact 需要特殊处理,因为它发生在工具循环中,模型后面还要继续工作。Codex 会把 initial context 插回到模型期望的位置,避免压缩后丢掉当前环境基线。
Pi 和 Codex 的核心差异
Pi 的 compact 是:
保留完整 session entries
追加 compaction entry
buildSessionContext() 动态构造压缩视图
Codex 的 compact 更像:
生成或请求一份 replacement_history
replace_compacted_history() 安装为新的 live history
对比:
Pi
= append-only 历史树 + compaction summary 边界 + 当前 Context.messages 视图重写
Codex inline
= summary + compacted replacement history
Codex remote
= 服务端返回结构化 replacement transcript
所以 Pi 更像“版本化历史 + 压缩视图”:
旧历史仍在 JSONL
当前发给模型的是 summary + kept recent messages + new messages
Codex 更像“当前工作上下文重写”:
active history 被替换成 compacted replacement_history
这也解释了两者设计取向不同:
- Pi 的实现更透明,适合学习和审计。
- Pi 的 append-only tree 对
/tree、分支、恢复更友好。 - Codex remote compact 更强,但服务端策略闭源。
- Codex remote compact 不只是总结文本,而是可以重写整个对话结构。
本节完成度
本节已经完成:
- 理解
Agent、AgentSession、SessionManager的职责边界。 - 理解为什么 session 必须保存
toolResult。 - 理解 append-only session tree。
- 理解
/tree如何从旧历史长出新分支。 - 理解
/tree、/fork、/clone、/new的区别。 - 理解为什么切分支后必须
buildSessionContext()。 - 理解 compaction 是上下文视图重写,不是删除历史。
下一节建议进入扩展系统:
05 看 pi 如何保存和恢复长期会话;
06 看 pi 如何把专用能力装进 skills、commands、templates 和 extensions。
Source: 05-session-notes.md