Session、分支与 Compaction
本文基于上述固定 revision,案例是用于解释状态变化的构造数据,未调用真实 Provider。摘要是有损的会话上下文,不保证保存全部事实,也不等同于跨 Session 自动检索的长期记忆。
本章要解决的问题
Section titled “本章要解决的问题”上一篇《Prompt 与 Context Engineering》已经走到:
SessionEntry[] → buildSessionContext() → AgentState.messages → Provider Context本章不再把 Session、Branch 和 Compaction 拆成三个孤立模块,而是沿着同一个 Session 持续向前走:
用户发出第一条消息 → AgentSession 监听 Agent 事件 → SessionManager 把消息追加为 JSONL entry → 程序重启,从 JSONL 恢复当前 branch → 用户回到旧节点,形成第二条分支 → 当前 branch 再次投影为 AgentState.messages → 对话增长到阈值 → 选择 firstKeptEntryId → 生成并持久化摘要 → 用摘要和保留区重建 AgentState → 下一次模型调用发生 overflow → 压缩后自动重试每一节都继续使用上一节产生的数据。读完后,你应该不需要逐行阅读源码,就能回答:
- 一条
message_end怎样变成 JSONL 中的一行; id、parentId、leafId怎样把线性写入变成会话树;- 完整 JSONL、当前 branch 和
AgentState.messages为什么是三份不同视图; /tree如何移动当前路径,Branch Summary 为什么挂在目标位置;/fork和/clone为什么不是同文件分支;_handlePostAgentRun()怎样进入自动压缩;prepareCompaction()怎样得到摘要区、Turn 前缀和保留区;CompactionEntry追加后,旧消息为什么还在磁盘却不再发给模型;- overflow 错误如何留在审计历史中,同时不污染重试 Context。
一、先固定贯穿全章的案例和四个状态
Section titled “一、先固定贯穿全章的案例和四个状态”假设用户让 Pi 修改认证模块。为了避免被真实消息的大字段干扰,本章省略启动时可能已追加的 model/thinking 等 metadata,给 entry 使用短 ID。这不是可直接导入的完整 Session fixture:
| ID | 含义 |
|---|---|
U1 |
用户:“检查认证实现” |
A1 |
Assistant 发起 read Tool Call |
T1 |
read Tool Result |
A2 |
Assistant 总结读取结果 |
U2 |
用户:“改成 JWT” |
A3 |
Assistant 完成 JWT 方案 |
BS1 |
离开 JWT 路径时生成的 Branch Summary |
U2B |
用户把原输入改为:“改成 session cookie” |
A4 |
Assistant 完成 cookie 方案 |
C1 |
第一次 CompactionEntry |
整章始终观察四个状态:
| 状态 | 由谁拥有 | 回答的问题 |
|---|---|---|
fileEntries |
SessionManager |
内存中有哪些文件条目;尚未 flush 时不等于磁盘内容 |
leafId |
SessionManager |
下一条 entry 会挂到哪里 |
current branch |
getBranch() |
当前 root 到 leaf 的路径是什么 |
AgentState.messages |
Agent |
下一次模型调用从什么消息历史开始 |
它们的关系不是四份重复数据:
fileEntries 完整事实账本,包含所有分支 + leafId ↓ getBranch()current branch 当前选择的一条 root-to-leaf path ↓ buildSessionContext()AgentState.messages 当前模型可工作的消息视图Compaction 只改变最后一个箭头的投影结果,不删除 fileEntries。
1.1 本章的阅读主线
Section titled “1.1 本章的阅读主线”本章不把 Session 当作一组零散 API,而是始终追踪同一次工作的生命周期:
开始对话 → 每轮交流进入完整历史 → 用户可能回到过去改走另一条路线 → 对话过长后,把旧内容逐步整理成摘要 → 模型始终使用“当前路线的滚动摘要 + 最近交流” → 即使发生 overflow,也尽量在不丢失完整历史的前提下恢复读完全部实现后,可以回到第二十节的功能总览图,把各段源码重新放回这条主线。
职责边界先记住:
SessionManager 只负责 entry、树、文件和 Context 投影 不调用摘要模型
compaction.ts 只负责估算、选切点和生成摘要 不写 Session 文件
AgentSession 连接 Agent 事件、SessionManager 和 Compaction 决定何时压缩、保存结果以及是否继续下面从 U1 真正进入系统开始。
1.2 三类能力不要混在一起
Section titled “1.2 三类能力不要混在一起”后文出现 /tree、/fork、/clone 和 Compaction 时,先按用户目的区分:
| 用户目的 | 功能含义 |
|---|---|
| 回到过去改走另一条路线 | 在同一份完整历史中切换当前分支 |
| 把某段经历独立出去继续 | 创建一份新的 Session |
| 对话太长但仍要继续当前任务 | 压缩当前分支的旧内容,不改变分支 |
分支解决“接下来沿哪条路线工作”,Compaction 解决“这条路线怎样放进有限 Context”。
二、第一条消息如何从 Agent 进入 Session
Section titled “二、第一条消息如何从 Agent 进入 Session”先看三个实际对象怎样接力:用户调用 AgentSession.prompt(),它让 Agent 开始运行;Agent 通过事件报告消息完成,AgentSession 收到事件后,再交给 SessionManager 保存。
这里有一个容易断开的连接:AgentSession 在运行开始前就订阅了 Agent。 所以 Agent.prompt() 不需要直接调用 SessionManager,也能让完成的消息进入 Session。
下面将跨文件逻辑合在一起。它是阅读用的缩略伪代码,不是可直接运行的实现;只展示普通消息路径,省略队列、扩展分支和错误处理。持久化部分针对启用文件保存、尚未 flush 的新 Session,案例仍省略启动 metadata。
// 预先接好事件通道:AgentSession 构造时订阅 Agentagent.subscribe(event => agentSession._handleAgentEvent(event));
// ① 发起运行(2.1):产品入口创建用户消息,再交给 AgentagentSession.prompt("检查认证实现");// 内部:创建 UserMessage U1 → _runAgentPrompt(U1) → agent.prompt(U1)// Loop 发出 message_end(U1);此时用户消息已加入对话,不是等模型回复。// Agent 处理该事件:先更新 AgentState.messages,再通知上面注册的订阅者。
// ② 接住事件(2.2):把完成的普通消息交给 SessionManagerasync function _handleAgentEvent(event) { await emitExtensionEvent(event); notifySessionListeners(event); if (event.type === "message_end" && isOrdinaryMessage(event.message)) { sessionManager.appendMessage(event.message); // U1 到达这里 }}
// ③ 包装并登记(2.3):消息正文 → 带树字段的 entry → 内存历史function appendMessage(message) { const entry = { type: "message", id: newId(), parentId: leafId, timestamp: now(), message, }; _appendEntry(entry);}function _appendEntry(entry) { fileEntries.push(entry); // 完整条目列表 byId.set(entry.id, entry); // 便于按 id 找父节点 leafId = entry.id; // 下一条消息接在它后面 _persist(entry); // 最后决定怎样写文件}
// ④ 后续消息复用同一条路(2.4),不需要新的保存机制// message_end(A1) → ② → ③ → ⑤:Assistant 的 Tool Call// message_end(T1) → ② → ③ → ⑤:工具返回结果// message_end(A2) → ② → ③ → ⑤:Assistant 的最终回答
// ⑤ 写入磁盘(2.5):内存有 entry,不代表文件已经创建function _persist(entry) { if (!flushed) { if (!hasUserOrAssistant(fileEntries)) return; // 只有初始化记录才延迟;此处为判定伪代码 writeAllEntriesAsJsonl(fileEntries); // A1 到达时,写 Header + U1 + A1 flushed = true; } else { appendOneJsonlLine(entry); // T1、A2 到达时,各追加一行 }}沿 U1 走一遍就是:创建用户消息 → Agent 发出完成事件 → AgentSession 接住 → SessionManager 包装成 entry → 更新内存 → 暂缓写文件。等 A1 到达,同一条路再走一次,才把积累的 Header、U1、A1 一起写入 JSONL。
下面的 2.1 至 2.5 按这五个步骤展开。文中的 U1、A1 等是案例标签;message 是消息正文,entry.message 保存该正文,而 entry.id / parentId 用于连接会话树。
2.1 发起运行:从用户输入到消息完成事件
Section titled “2.1 发起运行:从用户输入到消息完成事件”对应缩略代码的①。先跟着用户输入走到事件发出的位置:
AgentSession.prompt("检查认证实现") → 创建 UserMessage U1 → _runAgentPrompt(U1) → Agent.prompt(U1) → agent-loop 发出 message_start / message_endSession 持久化不是 Agent.prompt() 主动调用的。AgentSession 在构造时订阅 Agent,
统一通过
agent-session.ts
的 _handleAgentEvent() 消费事件。
这里 U1 的“完成”表示这条用户消息已加入对话,并不表示模型已经回答。通过预先注册的订阅,Agent 把下面的事件交给 _handleAgentEvent(),下一小节从这里接住:
{ type: "message_end", message: { role: "user", content: [{ type: "text", text: "检查认证实现" }], timestamp: ... }}2.2 接住事件:把消息交给 SessionManager
Section titled “2.2 接住事件:把消息交给 SessionManager”对应缩略代码的②。现在输入已经从用户文字变成了 message_end(U1) 事件。_handleAgentEvent() 先 await Extension 事件,再同步通知外部 listeners,之后才执行 append。append 是同步方法,不代表磁盘事务或 fsync 保证。
关键分支:
event.type !== message_end → 本段不持久化
message.role === custom → appendCustomMessageEntry()
message.role 是 user / assistant / toolResult → appendMessage()
bashExecution / branchSummary / compactionSummary → 由各自专用流程持久化U1 是普通 UserMessage,因此进入:
sessionManager.appendMessage(U1)这一跳完成了职责转换:
输入:AgentMessage输出目标:SessionMessageEntry2.3 包装并登记:给消息补上树结构
Section titled “2.3 包装并登记:给消息补上树结构”实现位于
session-manager.ts。
对应缩略代码的③。上一节交过来的是 U1 消息,appendMessage() 不改它的正文,只包一层 Session metadata:
const entry = { type: "message", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), message: U1,};this._appendEntry(entry);在本章省略启动 metadata 的简化案例中,初始 leafId = null,所以 U1 对应的 entry 得到:
U1.parentId = null随后 _appendEntry() 依次更新三处内存状态,再尝试持久化:
fileEntries.push(U1)byId.set(U1.id, U1)leafId = U1.id_persist(U1)执行完 U1 后:
fileEntries = [Header, U1]leafId = U1current branch = U1AgentState.messages = [U1]AgentState.messages 已由 Agent 的事件处理更新;SessionManager 保存的是用于恢复的事实副本。
2.4 复用通道:Tool Call 和 Tool Result 继续形成链
Section titled “2.4 复用通道:Tool Call 和 Tool Result 继续形成链”对应缩略代码的④。U1 已登记完毕,接下来模型与工具依次产生:
A1 = Assistant(toolCall: read auth.ts)T1 = ToolResult(read output)A2 = Assistant("认证逻辑位于...")每条消息结束都重复:
message_end → _handleAgentEvent() → appendMessage() → parentId = 当前 leaf → _appendEntry() → leaf 前移状态连续变化:
追加 A1: A1.parentId = U1; leaf = A1追加 T1: T1.parentId = A1; leaf = T1追加 A2: A2.parentId = T1; leaf = A2此时:
fileEntries = [Header, U1, A1, T1, A2]
current branch = U1 → A1 → T1 → A2
AgentState.messages = [User U1, Assistant A1, ToolResult T1, Assistant A2]到这里还没有分支,也没有压缩,三个视图看起来相同。后面它们才会逐渐分开。
2.5 写入磁盘:首条用户消息就会创建文件
Section titled “2.5 写入磁盘:首条用户消息就会创建文件”对应开头缩略代码的⑤。当前 _persist() 在启用持久化且尚未 flush 时调用 _hasConversation():只要已有 user 或 assistant message,就用 openSync(sessionFile, "wx") 创建文件并写入全部内存记录。
_persist(entry) { if (!this.persist || !this.sessionFile) return; if (!this.flushed) { if (!this._hasConversation()) return; // openSync(..., "wx");逐条写 fileEntries;关闭文件 this.flushed = true; } else { appendFileSync(this.sessionFile, JSON.stringify(entry) + "\n"); }}只有 Header、model/thinking 设置、SystemMessage:留在内存U1 到达:一次写入初始化记录 + U1,flushed=trueA1、T1、A2 到达:分别 append这与旧版本等待第一条 Assistant 不同。源码注释明确说明:即使首轮始终未完成,也要把用户 prompt 留在磁盘;同时只打开再关闭 Pi、不聊天时仍不生成空会话文件。
下文的 U1/A1/T1 示例省略初始化设置和 SystemMessage,以便观察对话树;真实文件可包含这些额外记录。_appendEntry() 仍先改内存再写磁盘,写入失败没有在这里统一回滚内存,因此不是内存与文件的原子事务。
三、程序重启后,JSONL 如何恢复成 AgentState
Section titled “三、程序重启后,JSONL 如何恢复成 AgentState”3.1 SessionManager.open() 先恢复事实层
Section titled “3.1 SessionManager.open() 先恢复事实层”打开已有 Session 时:
SessionManager.open(path) → 读取 Header 取得 cwd → new SessionManager(..., sessionFile) → _setSessionFile() → loadEntriesFromFile() → migrateToCurrentVersion() → _buildIndex()loadEntriesFromFile() 逐行解析 JSONL:
- 空行和非法 JSON 行跳过;
- 第一个成功解析的 entry 必须满足 type 为 session、id 为字符串的最小检查,不是完整 schema 校验;
- 文件来自旧版本时迁移到当前 v3。
当前版本还有文件尾修复:Header 通过检查且末尾存在未以换行结束的内容时,loadEntriesFromFile() 会追加一个换行,避免后续 append 与尾行粘连;它不修复非法 JSON 内容。读取路径因此可能写文件。
_buildIndex() 扫描全部普通 entry:
byId[U1] = U1byId[A1] = A1byId[T1] = T1byId[A2] = A2leafId = 最后一个物理 entry A2重启后的事实层恢复为:
fileEntries = [Header, U1, A1, T1, A2]leafId = A2注意 leafId 本身不写入 Header。重载时最后物理 entry 默认成为活动 leaf。
3.2 从恢复文件转向恢复模型上下文:先选出当前路径
Section titled “3.2 从恢复文件转向恢复模型上下文:先选出当前路径”上一节结束时,SessionManager 已经把文件读回内存,得到了 fileEntries、byId 和 leafId。但这只说明“历史记录找回来了”,还没有得到可以交给 Agent 的 messages:完整文件可能包含其他分支、配置记录和压缩记录,不能直接整份发送给模型。
接下来由创建运行时的 createAgentSession() 发起上下文恢复。它拿着上一节打开的 SessionManager,调用 sessionManager.buildSessionContext(),要求它把完整历史整理成当前会话的工作上下文。
把这次交接展开,调用关系是:
上一节:SessionManager.open(path) → 恢复 fileEntries、byId、leafId
接下来:createAgentSession({ sessionManager, ... })→ sessionManager.buildSessionContext() 对象上的方法 → this.buildSessionProjection() 保留 entry 来源 → buildSessionProjection(entries, leafId, byId) 文件内纯函数 → buildSessionPath() 当前路径 → getSessionContextSettings() 运行配置 → buildContextEntries() 压缩感知的上下文区间 → projectContextEntry() 消息转换 + context_edit → 返回 { messages, thinkingLevel, model }类方法的实际交接缩写为:
buildSessionProjection() { return buildSessionProjection(this.getEntries(), this.leafId, this.byId);}buildSessionContext() { const { messages, thinkingLevel, model } = this.buildSessionProjection(); return { messages, thinkingLevel, model };}同文件另有供外部使用的纯函数 buildSessionContext,它也委托给纯函数 buildSessionProjection;对象方法并不再调用同名纯函数。
所以 buildSessionPath() 是规范投影内部的第一步,不是突然启动的另一套恢复流程。它回答一个具体问题:已知当前停在 A2,哪些 entry 是沿着这一条分支走过来的?
它使用刚传进来的 leafId 和 byId,沿父节点向前找:
从 A2 开始 → parentId 找到 T1 → parentId 找到 A1 → parentId 找到 U1 → U1.parentId = null,停止 → reverse()输出:
path = [U1, A1, T1, A2]这一步只做树选择,还没有做 Compaction,也没有过滤非消息 entry。
3.3 配置和消息从这里分成两条支线
Section titled “3.3 配置和消息从这里分成两条支线”上一节选出的 path = [U1, A1, T1, A2],继续交给 buildSessionContext() 做两类处理:恢复运行配置,以及选择模型上下文所需的 entries。以下对话图省略 system 状态快照与不贡献消息的 entries。下面是数据关系图;实现中 buildContextEntries(entries, leafId, byId) 会自己再调用 buildSessionPath() 得到同一条路径,并非直接接收上一步的 path 参数。
完整 path ├→ getSessionContextSettings() │ 恢复 thinkingLevel 和 model │ └→ buildContextEntries() 选择真正进入 Context 的 entry 区间为什么配置使用完整 path?
假设早期有:
thinking_level_change(high)即使它后来被 Compaction 排除出消息区间,当前 reasoning 配置仍应恢复为 high。
getSessionContextSettings() 按 path 顺序覆盖:
thinking_level_change → thinkingLevel = entry.thinkingLevel
model_change → model = entry.provider / entry.modelId
AssistantMessage → model = 该历史回复实际使用的 provider / model我们的案例尚无 Compaction,所以:
buildContextEntries(entries, leafId, byId) = [U1, A1, T1, A2]3.4 sessionEntryToContextMessages() 完成 entry 到消息的投影
Section titled “3.4 sessionEntryToContextMessages() 完成 entry 到消息的投影”转换规则:
| SessionEntry | 输出到 AgentMessage[] |
|---|---|
message |
entry 内原始 AgentMessage |
custom_message |
CustomMessage |
branch_summary |
BranchSummaryMessage |
compaction |
可选 SystemMessage + CompactionSummaryMessage |
context_edit |
自身无消息;在规范投影中编辑目标 |
usage |
仅统计,无消息 |
model_change 等 metadata |
不输出消息 |
custom |
不输出消息 |
因此:
buildSessionContext() = { messages: [U1, A1, T1, A2], thinkingLevel: ..., model: ... }createAgentSession() 把结果接回运行时:
agent.state.messages = existingSession.messages;恢复链到这里闭合:
JSONL → fileEntries + byId + leafId → root-to-leaf path → context-visible entries → AgentMessage[] → AgentState.messages下一条用户消息将从这份 AgentState.messages 继续,而不是重新读取整个 JSONL。
3.5 新版的规范投影与 context_edit
Section titled “3.5 新版的规范投影与 context_edit”buildSessionContext() 现在委托给 buildSessionProjection()。后者既返回 messages,也保留每组消息来自哪条 entry,恢复流程才能按 entry ID 排除失败尝试,而不依赖数组尾部位置。
const contextEntries = buildContextEntries(entries, leafId, byId);const edits = new Map();for (const entry of contextEntries) { if (entry.type === "context_edit") edits.set(entry.targetId, entry);}const projected = contextEntries.map((sourceEntry, index) => ({ sourceEntry, messages: sourceEntry.type === "compaction" && index > 0 ? [] : projectContextEntry(sourceEntry, edits.get(sourceEntry.id)),}));// projectContextEntry:replacement=null → [];否则替换 content,保留其他字段。同一目标以当前上下文区间内最后一条编辑为准。appendContextEdit() 校验目标位于活动分支,且为可编辑的 user、assistant、toolResult 或 custom_message;不能用它直接改 SystemMessage。记录是追加的,原始 entry 没有覆盖;切换到不含该编辑的分支也不会凭空继承编辑。
压缩记录还携带当前系统状态快照 systemMessage。最新 Compaction 投影时先产生这个 SystemMessage,再产生 summary;保留区在 checkpoint 之前的 system 增量不重复加入,checkpoint 之后的新 system 更新仍有效。
四、继续对话后,何时真正形成一棵树
Section titled “四、继续对话后,何时真正形成一棵树”用户继续:
U2 = “改成 JWT”A3 = “JWT 方案已经完成”正常追加后:
U1 → A1 → T1 → A2 → U2 → A3 (leaf)四个状态:
fileEntries = [Header, U1, A1, T1, A2, U2, A3]
leafId = A3
current branch = U1 → A1 → T1 → A2 → U2 → A3
AgentState.messages = [U1, A1, T1, A2, U2, A3]现在用户发现 JWT 方向不对,打开 /tree,选中旧的 UserMessage U2,希望把它改成
“改成 session cookie”。从这里开始,物理 JSONL、当前路径和模型消息第一次分离。
4.1 navigateTree(U2) 先读取旧位置,不立即移动 leaf
Section titled “4.1 navigateTree(U2) 先读取旧位置,不立即移动 leaf”入口:
agent-session.ts的navigateTree()
初始数据:
oldLeafId = A3targetId = U2函数先验证 target,再调用:
collectEntriesForBranchSummary(sessionManager, A3, U2)它分别取得:
old path = U1 → A1 → T1 → A2 → U2 → A3target path = U1 → A1 → T1 → A2 → U2最深公共祖先是 U2。随后从 old leaf A3 向上收集,到 U2 前停止:
entriesToSummarize = [A3]commonAncestorId = U2这里不总结 U2,因为 U2 的原文马上会返回编辑器。
4.2 Branch Summary 成功前,Session 状态保持不变
Section titled “4.2 Branch Summary 成功前,Session 状态保持不变”如果用户选择 summarize:
entriesToSummarize [A3] → prepareBranchEntries() → generateBranchSummary()prepareBranchEntries() 从新到旧装入 token budget:
- 普通消息转换为 AgentMessage;
- ToolResult 跳过,避免把巨大工具输出重复带入摘要;
- Tool Call 中的文件操作另外提取;
- 已有 Branch/Compaction Summary 的文件清单从
details累计。
generateBranchSummary():
AgentMessage[] → convertToLlm() → serializeConversation() → <conversation>...</conversation> → 独立摘要请求 → “JWT 路径完成了什么”的结构化 summary在这次调用成功前:
leafId = A3fileEntries 没有新增 entryAgentState.messages 没有改变默认导航在摘要返回错误或 abort 时先退出,不推进 leaf。这是先准备、再修改路径的执行顺序,不是通用事务;Extension 副作用、之后的写入或事件失败没有统一回滚保证。
Branch Summary 为什么是可选项
Section titled “Branch Summary 为什么是可选项”/tree 改变的是会话路径,不是代码仓库快照。离开的分支可能已经:
修改了工作区文件运行了会影响后续判断的命令或测试发现了隐藏约束证明某个方案不可行切换 Session branch 不会自动撤销这些副作用。例如旧 JWT 分支已经修改 auth.ts,
回到 A2 后,模型的对话 Context 看似回到了修改前,但磁盘上的 auth.ts 仍可能是 JWT
版本。如果完全丢掉 A3,模型就不知道当前工作区为什么与公共历史不一致。
Branch Summary 用来保存的是“离开的分支发生过什么”,不是宣布“旧方案继续有效”。 默认摘要会带有来源说明,明确这是另一条对话分支中的探索,让后续模型判断哪些内容应当:
继续复用作为失败经验保留主动撤销按新方向重新实现但它确实可能引入已经过时的信息,所以 navigateTree() 通过 options.summarize 让调用者选择:
旧分支有文件副作用、重要发现或失败经验 → summarize = true
旧分支完全无价值,希望获得干净的新 Context → summarize = false → 只移动 leaf,不创建 BranchSummaryEntry4.3 选中 UserMessage 时,真正的继续点是它的 parent
Section titled “4.3 选中 UserMessage 时,真正的继续点是它的 parent”摘要成功后,navigateTree() 判断 target 类型:
target U2 是 UserMessage → newLeafId = U2.parentId = A2 → editorText = “改成 JWT”这条规则的语义是:
用户不是要从 U2 之后继续,而是要回到发送 U2 之前,修改 U2 再发送。
如果选择的是 AssistantMessage,则:
newLeafId = selected Assistant ideditorText = undefined4.4 branchWithSummary() 把旧路径信息挂到新继续点
Section titled “4.4 branchWithSummary() 把旧路径信息挂到新继续点”这里先区分三段内容:
U1 → A1 → T1 → A2└────────────────┘新旧路径共享的前缀保留原始 entry,不需要总结
U2用户准备重新编辑的输入返回编辑器,不放进 Branch Summary
A3离开旧分支时不再直接可见的工作被整理进 BS1因此 BS1 不是“U1 到 A3 的全量摘要”,而是对 entriesToSummarize = [A3] 的摘要。
它的作用是把旧分支独有、即将离开 Context 的信息带到新路径。它不会替换公共前缀。
因为已有 summary,navigateTree() 不只是调用 branch(A2),而是:
sessionManager.branchWithSummary(A2, summary, ...)branchWithSummary() 先把:
leafId = A2再创建:
BS1.parentId = A2BS1.fromId = A2leafId = BS1此时完整树是:
共享且原样保留U1 → A1 → T1 → A2 ┬→ U2 → A3 旧 JWT 路径 \ BS1 (new leaf) 只总结将离开的 A3Branch Summary 挂在目标继续点,不挂在被离开的 A3 后面。只有这样,它才会进入新路径。
这里的 fromId 也保存目标继续点 A2;真正被离开的旧 leaf A3 只用于前面的收集过程,
不要把 fromId 误读成 oldLeafId。
这里和后面的 Compaction Summary 有本质区别:
| 摘要 | 总结什么 | 是否从模型 Context 中替换旧节点 |
|---|---|---|
| Branch Summary | 离开的分支相对公共祖先多出来的内容 | 否;公共前缀继续原样保留 |
| Compaction Summary | 当前分支中切点以前的旧历史 | 是;投影时用摘要替代旧历史 |
4.5 navigateTree() 立即重建 AgentState
Section titled “4.5 navigateTree() 立即重建 AgentState”函数最后不是只移动 leaf,而是继续调用:
sessionManager.buildSessionContext() → buildSessionPath(leaf = BS1) → [U1, A1, T1, A2, BS1] → sessionEntryToContextMessages() → [U1, A1, T1, A2, BranchSummary(BS1)]然后:
agent.state.messages = sessionContext.messages;现在四个状态已经分开:
fileEntries = [Header, U1, A1, T1, A2, U2, A3, BS1] 包含旧 JWT 路径和新 Branch Summary
leafId = BS1
current branch = U1 → A1 → T1 → A2 → BS1
AgentState.messages = [U1, A1, T1, A2, BranchSummary(BS1)]旧的 U2、A3 还在文件中,但不是当前 leaf 的祖先,所以不进入 AgentState。
4.6 修改后的输入从 BS1 后继续追加
Section titled “4.6 修改后的输入从 BS1 后继续追加”UI 把原 U2 文本放回编辑器。用户改成:
U2B = “改成 session cookie”再次执行 AgentSession.prompt() 后,前面第二节的同一持久化链重新工作:
message_end(U2B) → appendMessage(U2B) → U2B.parentId = current leaf BS1 → leafId = U2B
message_end(A4) → appendMessage(A4) → A4.parentId = U2B → leafId = A4最终树:
U1 → A1 → T1 → A2 ┬→ U2 → A3 JWT 路径 \ BS1 → U2B → A4 cookie 路径,当前 leaf当前 Context:
[U1, A1, T1, A2, BranchSummary(BS1), U2B, A4]到这里,Session Tree 的创建、导航和 Context 更新已经串成了一条闭环:
移动 leaf → 追加 Branch Summary → buildSessionContext() → 覆盖 AgentState → 新消息按新 leaf 继续追加五、/tree、/fork、/clone 在这条主线上的分岔
Section titled “五、/tree、/fork、/clone 在这条主线上的分岔”上一节的 /tree 留在同一个 JSONL 中。用户也可以选择创建一个独立 Session。
| 操作 | 底层入口 | 新文件 | 新 Session 中复制到哪里 |
|---|---|---|---|
/tree |
AgentSession.navigateTree() |
否 | 不复制,只移动同文件 leaf |
/fork |
AgentSessionRuntime.fork(position: "before") |
是 | 选中 UserMessage 的 parent |
/clone |
AgentSessionRuntime.fork(position: "at") |
是 | 当前 leaf |
5.1 如果对 U2 执行 /fork
Section titled “5.1 如果对 U2 执行 /fork”入口位于
agent-session-runtime.ts
的 fork():
selectedEntry = U2position = before → targetLeafId = U2.parentId = A2 → selectedText = “改成 JWT”随后:
SessionManager.open(currentFile) → createBranchedSession(A2) → getBranch(A2) → [U1, A1, T1, A2] → 创建新 Header → parentSession 指向旧文件 → 写入这条 path → 重建整个 AgentSession runtime新文件不包含 U2/A3,也不包含 cookie 分支。selectedText 返回编辑器等待修改。
5.2 如果在 A4 执行 /clone
Section titled “5.2 如果在 A4 执行 /clone”Interactive Mode 把 /clone 转为:
runtimeHost.fork(A4, { position: "at" })createBranchedSession(A4) 复制当前活动路径:
U1 → A1 → T1 → A2 → BS1 → U2B → A4不会返回待修改的 UserMessage,也不会复制 JWT 兄弟分支。
这里的新文件指持久化 Session,实际创建遵循已有 user 或 assistant message 的写入规则。createBranchedSession() 也不是逐字节复制:它会移除原 path 中的 label entries、重接 parentId,再重建有效标签。SDK 内存 Session 不创建 JSONL 文件。
5.3 为什么 fork 后需要重建 runtime
Section titled “5.3 为什么 fork 后需要重建 runtime”AgentSessionRuntime 不只替换一个 SessionManager:
abort 旧 AgentSession 并等待 idle → session_shutdown → dispose old session → createRuntime(new cwd, new SessionManager) → 重建 cwd-bound ResourceLoader、tools、extensions、settings → buildSessionContext() → 绑定新 AgentSession所以:
/tree = 同一个 runtime 中改变 Context path
/fork、/clone = 创建新 Session 后替换整个 runtime现在回到 /tree 形成的 cookie 当前分支,继续让它增长,进入 Compaction 主线。
六、对话增长后,自动压缩从哪里开始
Section titled “六、对话增长后,自动压缩从哪里开始”假设 cookie 分支继续产生多轮消息:
... → U3 → A5 → U4 → A6 → U5 → A7 (leaf)完整当前 branch:
U1 → A1 → T1 → A2 → BS1 → U2B → A4 → U3 → A5 → U4 → A6 → U5 → A76.1 自动检查有三个时机
Section titled “6.1 自动检查有三个时机”当前 AgentSession 有三个相关入口:
| 时机 | 入口 | 作用 |
|---|---|---|
| 新输入进入 Agent 前 | prompt() → _checkCompaction(lastAssistant, false) |
处理先前响应留下的压缩需求,不在这里 continue 旧任务 |
| 同 Run 确定继续、下一 Turn 前 | prepareNextTurnWithContext → _compactBeforeNextAssistantResponse() |
按累积消息估算检查 threshold,尝试压缩并刷新 Loop Context |
| Core Run 结束后 | _handlePostAgentRun() → _checkCompaction() |
处理 threshold、overflow 或可恢复的 length,决定是否另起 Core Run 继续 |
第二条用于工具输出迅速撑大 Context 等情况:不必等待整个 Run 结束。先检查压缩,再调用原 prepare hook、刷新配置,Loop 才发下一次 turn_start 并请求模型;没有下一 Turn 时不必执行。
下面沿案例中 A7 已完成的第三条路径继续:
AgentSession._runAgentPrompt() → await agent.prompt(...) → Agent 发出 agent_end → _handlePostAgentRun()_handlePostAgentRun() 先处理普通可重试错误,再处理 Compaction:
last Assistant 不存在 → false
普通 retryable error → _prepareRetry()
否则 → _checkCompaction(lastAssistant)这一顺序说明:
- 网络错误、限流等由普通 retry 处理;
- Context overflow 不属于普通 retry;
- 成功回复也可能因接近阈值触发压缩。
6.2 _checkCompaction(A7) 先排除不应处理的消息
Section titled “6.2 _checkCompaction(A7) 先排除不应处理的消息”输入是刚完成的 A7,而不是重新扫描所有 Assistant:
settings.enabled 为 false → 不压缩
A7 是 aborted 且使用默认检查模式 → 不压缩
A7 早于最新 CompactionEntry → 是 stale usage,不压缩如果是 overflow,_modelForMessage(A7) 要找到适用的实际模型:物理选择需与 A7 匹配,虚拟选择则从 A7 的 provider/model 解析物理模型。不能拿虚拟目录项的窗口计算这次失败响应的预算。
6.3 Context token 以最后一个有效 usage 为锚点
Section titled “6.3 Context token 以最后一个有效 usage 为锚点”正常成功的 A7 自带 Provider usage:
contextTokens = calculateContextTokens(A7.usage)优先使用 usage.totalTokens,缺失时才把 input、output、cacheRead、cacheWrite 相加。
如果 A7 是 error 或 usage 全零:
estimateContextTokens(agent.state.messages) → 找最后一条非 error、非 aborted、非零 usage 的 Assistant → usageTokens 作为已知 Context 锚点 → 对它之后的消息做 chars / 4 估算这样临时 529 错误不会把 Context 使用量错误重置为零。当前产品层以 estimateProjectedContextTokens(projection, branch) 估算规范投影:旧 usage 之后的编辑或压缩会使锚点失效,回退为当前系统状态和对话的估算,不能继续信任编辑前的总数。
6.4 Threshold 只决定“要压缩”,不决定“保留哪里”
Section titled “6.4 Threshold 只决定“要压缩”,不决定“保留哪里””判断式:
contextTokens > contextWindow - reserveTokens例如:
contextWindow = 128kreserveTokens = 16kcontextTokens = 115k
115k > 112k → _runAutoCompaction("threshold", willRetry = false)reserveTokens 用来留出下一次输出空间。真正要保留多少最近消息,由另一个配置
keepRecentTokens 决定。
这一节的输出是:
reason = thresholdwillRetry = falsepathEntries = sessionManager.getBranch()settings = { reserveTokens, keepRecentTokens, ... }这些值直接成为下一节 prepareCompaction() 的输入。当前 getCompactionSettings(model) 还支持按精确 provider/modelId 设置 modelOverrides,覆盖 reserveTokens/keepRecentTokens;默认仍为 16384/20000。
七、prepareCompaction() 如何把当前 branch 切成三段
Section titled “七、prepareCompaction() 如何把当前 branch 切成三段”入口位于
compaction.ts:
prepareCompaction(pathEntries, settings)此时它接收的是当前 cookie branch,随后先生成已应用压缩与 context_edit 的 projection,切点在 projectedEntries 上计算:
[U1, A1, T1, A2, BS1, U2B, A4, U3, A5, U4, A6, U5, A7]不是全部 fileEntries,因此 JWT 兄弟分支 U2 → A3 不会参与 cookie 分支的压缩。
7.1 第一步:确定本轮可处理边界
Section titled “7.1 第一步:确定本轮可处理边界”第一次压缩时没有旧 Compaction:
prevCompactionIndex = -1previousSummary = undefinedboundaryStart = 0boundaryEnd = projectedEntries.length如果当前最后一条 entry 已经是 CompactionEntry,函数直接返回 undefined,避免在没有新消息
时反复压缩同一份 Context。
7.2 第二步:估算压缩前的工作 Context
Section titled “7.2 第二步:估算压缩前的工作 Context”函数不是把每个 path entry 简单相加,而是:
const projection = buildSessionProjection(pathEntries);const projectedEntries = projection.entries;const tokensBefore = estimateProjectedContextTokens(projection, pathEntries).tokens;这很重要:如果 path 中已经有旧 Compaction,tokensBefore 应描述当前模型工作 Context,而不是完整磁盘历史。若最近可用 usage 之后发生 context_edit 或 compaction,旧 usage 不再可靠,函数改为估算当前系统状态与非 system 消息。SystemMessage 不送给摘要模型总结;其状态另存在 checkpoint。
7.3 第三步:findProjectedCutPoint() 从最新消息向前保留
Section titled “7.3 第三步:findProjectedCutPoint() 从最新消息向前保留”假设:
keepRecentTokens = 20k算法从 A7 向前扫描:
A7 tokens + U5 tokens + A6 tokens + U4 tokens + ...累计达到 20k 后,在当前位置或之后选择最近的合法 cut point;若后方全是大段 ToolResult 而无合法切点,则退到最后一个合法切点,保住前面的 Assistant ToolCall。假设最终选择 U4:
firstKeptEntryIndex = index(U4)firstKeptEntryId = U4于是当前 branch 被分成:
待总结历史 [U1, A1, T1, A2, BS1, U2B, A4, U3, A5]
原样保留区 [U4, A6, U5, A7]7.4 为什么 ToolResult 不能成为切点
Section titled “7.4 为什么 ToolResult 不能成为切点”合法切点包括:
userassistantbashExecutioncustom messagebranch summaryToolResult 不合法。否则可能得到:
Context 开头 = ToolResult(call_123)而产生 call_123 的 Assistant ToolCall 已被摘要掉,Provider 会拒绝不成对的工具协议。
切在带 ToolCall 的 Assistant 前则安全,因为后续 ToolResult 会随它一起保留。
7.5 第四步:不可见 metadata 跟随相邻保留区
Section titled “7.5 第四步:不可见 metadata 跟随相邻保留区”初步切在 U4 后,算法还会向前查看相邻 entry:
model_changethinking_level_changecustomlabelsession_info这些 entry 不产生 Context message,不消耗 token。只要前面没有遇到另一个 Context-visible entry 或旧 Compaction 边界,就把它们一起纳入保留区,避免在结构上孤立。
7.6 第五步:组装 CompactionPreparation
Section titled “7.6 第五步:组装 CompactionPreparation”正常完整 Turn 边界下:
messagesToSummarize = [U1, A1, T1, A2, BS1, U2B, A4, U3, A5]
turnPrefixMessages = []
isSplitTurn = false
firstKeptEntryId = U4
tokensBefore = 当前 compact-aware Context token
previousSummary = undefined同时从待总结消息中的 Tool Call 提取:
fileOps.readfileOps.editedfileOps.writtenkeepRecentTokens 是近似预算,不是硬上限;文本、图片估算及合法切点约束都会影响保留量。
这份 CompactionPreparation 是纯数据。prepareCompaction() 不调用模型,也不写 Session。
下一节 compact() 只消费这份准备结果。
八、如果切点落在一个超长 Turn 中间
Section titled “八、如果切点落在一个超长 Turn 中间”上一节切在 U4,是完整对话段的边界。现在假设一次用户请求产生了很长的工具执行链,切点落在这段请求中间。先只展开这一段,U6 之前的历史仍然存在,稍后一起放回图中:
U6 → A8(tool call) → T2 → A9(tool call) → T3 → A10(tool call) → T4 → A11如果这个 Turn 本身超过 keepRecentTokens,切点可能只能落在 A10。
此处 Turn 是 compaction 按 user-like 消息划分的对话段,可能包含多次模型调用;与 Agent Loop 的一次 Assistant 响应及工具执行不是同一粒度。user、bashExecution、custom、branchSummary 等都可能作为该对话段开头。
8.1 findProjectedTurnStartIndex() 找回被切开的请求开头
Section titled “8.1 findProjectedTurnStartIndex() 找回被切开的请求开头”cut entry = A10A10 不是 Turn start → 从 A10 向前扫描 → 找到 U6得到:
turnStartIndex = index(U6)firstKeptEntryIndex = index(A10)isSplitTurn = true8.2 Preparation 改成三段
Section titled “8.2 Preparation 改成三段”messagesToSummarize = 本次可处理范围内、U6 之前的历史消息
turnPrefixMessages = [U6, A8, T2, A9, T3]
原样保留区 = [A10, T4, A11]三段的处理范围来自当前 Session 分支,不只限于本次 Agent Run 新产生的消息。首次压缩时,可以从分支开头处理;已有压缩时,本次最早可处理边界由上一份 checkpoint 的 firstKeptEntryId 决定,详见第十一节。
8.3 第一次压缩,也可以生成 history summary
Section titled “8.3 第一次压缩,也可以生成 history summary”这里先区分三个名称:
| 名称 | 指什么 | 第一次压缩时 |
|---|---|---|
previousSummary |
上一次压缩产生的摘要正文 | 没有 |
messagesToSummarize |
本次需要总结、位于 U6 之前的历史消息 | 可以有很多 |
| history summary | 本次根据这些历史生成的新摘要;有 previousSummary 时还用于更新合并 | 有待总结历史就会生成 |
没有 previousSummary,不等于没有 history summary。 是否生成 history summary,取决于 messagesToSummarize 是否为空。
例如此前一次都没有压缩过,用户已经讨论了两轮,现在要求修改代码:
① U6 之前的历史 用户:检查认证实现 Assistant:读取代码并解释 用户:设计改造方案 Assistant:给出方案 → messagesToSummarize → 本次生成 history summary
② 当前长对话段的前缀 U6:按方案修改并测试 A8:调用 read → T2:返回代码 A9:调用 edit → T3:返回修改结果 → turnPrefixMessages → 本次生成 turn-prefix summary
── 切点位于 A10 之前 ──
③ 当前长对话段的后缀 A10:调用 bash → T4:返回测试结果 A11:解释结果 → 原样保留,不进入这次摘要这里第一次压缩就会生成两段摘要。前面的用户对话并没有被忽略:它进入 history summary;当前请求的开头及早期工具执行进入 turn-prefix summary;最近的后缀保留原始消息。
为什么要单独总结前缀?如果只有旧历史的摘要,保留区又从 A10 突然开始,模型就缺少 U6 的具体要求、A8/A9 已完成的工作,以及为什么继续执行 A10 的背景。因此新版前缀摘要要求说明 Original Request、Progress So Far 和 Context Needed to Continue,只总结显式出现的信息,不推测保留区后续内容。
8.4 两段摘要怎样进入一个 summary 字符串
Section titled “8.4 两段摘要怎样进入一个 summary 字符串”两段都生成时,compact() 把它们拼接为一个字符串:
history summary 的正文
---
**Turn Context (split turn):**
turn-prefix summary 的正文这里是两段摘要,加一个固定标题。Turn Context (split turn): 不是第三份摘要,也不是独立字段。拼接后的正文随后还会追加文件操作清单,再交给 AgentSession 保存到同一个 CompactionEntry.summary,只追加一个 C1。
8.5 什么情况下没有 history summary
Section titled “8.5 什么情况下没有 history summary”假设整个会话一开始就是 U6,用户的第一项任务便产生了很长的工具执行链:
U6:完成这个复杂任务 → A8 → T2 → A9 → T3 → 切点 → A10 → T4 → A11U6 前面没有待总结的对话,所以:
messagesToSummarize = []turnPrefixMessages = [U6, A8, T2, A9, T3]这时实现不调用 history 摘要生成器,使用固定文字 No prior history.,只生成 turn-prefix summary。原因是当前长对话段之前没有待总结消息,而不是以前没有压缩过。
还需保留一个源码边界:split-turn 分支只在 messagesToSummarize.length > 0 时调用 history 摘要生成器并传入 previousSummary;若该数组为空,即便存在 previousSummary,这个分支也不会自动把它拼入 historyText。因此不能把上述常见流程理解成任意 split-turn 都保证继承旧摘要。
九、compact() 如何把 Preparation 变成可持久化结果
Section titled “九、compact() 如何把 Preparation 变成可持久化结果”回到正常案例:
messagesToSummarize = [U1 ... A5]firstKeptEntryId = U4isSplitTurn = false9.1 generateSummaryWithUsage() 不直接重放旧多轮消息
Section titled “9.1 generateSummaryWithUsage() 不直接重放旧多轮消息”调用链:
compact(preparation, model, auth, ...) → generateSummaryWithUsage(messagesToSummarize) → convertToLlm() → serializeConversation()产品自定义消息先收敛为标准 LLM Message,再序列化为类似:
[User]: 检查认证实现[Assistant tool calls]: read(...)[Tool result]: ...[Assistant]: ...[User]: The following is a summary of a branch ...然后整体包进:
<conversation>...</conversation>作为一条新的 summarization UserMessage 发送。
为什么不把旧消息原样作为聊天历史?
摘要模型的任务是描述这段对话,不是继续扮演其中的 Assistant。文本序列化把“被总结材料” 和“本次真实对话”分开。
9.2 Summary 是结构化 Context checkpoint
Section titled “9.2 Summary 是结构化 Context checkpoint”默认要求保留:
GoalConstraints & PreferencesProgress Done In Progress BlockedKey DecisionsNext StepsCritical Context并尽量保留精确文件路径、函数名和错误信息。输出不是为了给人写聊天回顾,而是为了让另一个 LLM 从 checkpoint 继续工作。
9.3 文件操作不完全依赖模型记忆
Section titled “9.3 文件操作不完全依赖模型记忆”prepareCompaction() 已经确定性地收集 file operations。摘要生成后:
computeFileLists(fileOps) → readFiles → modifiedFiles
formatFileOperations() → 把清单追加到 summary 文本
CompactionResult.details = { readFiles, modifiedFiles }清单来自已识别的工具调用及符合条件的旧摘要 details,不是文件系统审计。Bash 内部修改、未识别工具,以及调用是否真正成功,都不能仅凭清单完整判定。
9.4 摘要调用自己的 retry
Section titled “9.4 摘要调用自己的 retry”所有摘要请求通过 completeSummarization():
临时 terminated / socket drop → retryAssistantCall() → 按 settings.retry 重试
quota 等确定性错误 → 立即失败
AbortSignal → 可中断请求或 retry backoff这里重试的是“摘要模型调用”。后面 overflow 场景中的 retry 重试的是“原 Agent Turn”, 两者处于不同层。
9.5 不是返回文本就能保存为 checkpoint
Section titled “9.5 不是返回文本就能保存为 checkpoint”默认摘要路径调用 getSummarizationFailure():error 表示失败,length 表示输出触及 token cap、摘要不完整,都会拒绝保存;返回 Tool Call 也会被拒绝。history、turn-prefix 和 Branch Summary 都有相关检查,上层写入前还会检查取消信号。
这些检查验证响应是否完成,不验证摘要的事实正确性和完整性。Extension 直接提供的结果也不自动经过默认模型响应检查。
9.6 compact() 返回结果,交给 AgentSession 保存
Section titled “9.6 compact() 返回结果,交给 AgentSession 保存”正常返回:
{ summary, firstKeptEntryId: "U4", tokensBefore, usage, details: { readFiles, modifiedFiles }}摘要生成函数 compact() 到这里返回 CompactionResult,不负责调用 SessionManager 保存记录,也没有为结果创建 id、parentId。
这里要区分同名入口:compaction.ts 的 compact() 负责生成结果;AgentSession.compact() 是手动压缩的编排方法,包含保存和状态更新。当前案例走自动压缩,下一节回到调用方 AgentSession._runAutoCompaction(),看它怎样接过结果。
十、CompactionResult 如何改变 Session,却不删除历史
Section titled “十、CompactionResult 如何改变 Session,却不删除历史”上一节的摘要生成函数已经返回结果,但 Session 和 AgentState 尚未因这个返回值而改变。接下来由 AgentSession 完成“保存记录 → 重建上下文 → 更新运行状态”。先把交接过程合在一起看:
// 阅读用的缩略代码:展示默认摘要路径,省略鉴权、hooks、取消检查和错误处理。// 实际 AgentSession 经 _runDefaultCompaction() 调用摘要生成函数 compact()。const result = await compact(preparation, ...);// 此时只有 CompactionResult,尚未追加压缩记录。
// ① 保存记录(10.1):AgentSession 把结果交给 SessionManagersessionManager.appendCompaction( result.summary, result.firstKeptEntryId, result.tokensBefore, result.details, false, // 默认生成路径,fromExtension = false result.usage,);// 内部:创建 CompactionEntry → _appendEntry() → _persist()
// ② 重建上下文(10.2):根据刚追加的压缩记录选择摘要和保留区const projection = sessionManager.buildSessionProjection();
// ③ _refreshFinalizedContext() 同时记录 message → sourceEntry.id 映射agent.state.messages = projection.messages;所以“生成摘要”和“保存摘要”是相邻的两个步骤,由不同对象负责,并不是整个压缩流程结束后没有持久化。下面的 10.1 至 10.3 分别展开代码中的①②③。
10.1 appendCompaction() 把结果挂到当前 leaf
Section titled “10.1 appendCompaction() 把结果挂到当前 leaf”_runAutoCompaction() 收到结果后调用:
sessionManager.appendCompaction( summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);压缩前 leaf 是 A7,因此新 entry:
C1.id = 新短 IDC1.parentId = A7C1.firstKeptEntryId = U4C1.summary = 旧历史 checkpointleafId = C1完整 JSONL path 仍然是:
U1 → A1 → T1 → A2 → BS1 → U2B → A4 → U3 → A5 → U4 → A6 → U5 → A7 → C1没有任何旧 entry 被删除或改写。
10.2 buildSessionContext() 第二次出现,但结果已经不同
Section titled “10.2 buildSessionContext() 第二次出现,但结果已经不同”_runAutoCompaction() 紧接着调用:
sessionManager.buildSessionContext()第一步仍是 buildSessionPath(leaf = C1),得到完整当前 path。
第二步 getSessionContextSettings(fullPath) 仍从完整路径恢复 model/thinking。
变化发生在 buildContextEntries():
扫描 path,找到最新 Compaction C1 → contextEntries 先放 [C1] → 在 C1 之前寻找 firstKeptEntryId U4 → 放入 [U4, A6, U5, A7] → 再放 C1 之后的新 entries;当前为空得到:
buildContextEntries() = [C1, U4, A6, U5, A7]再投影成:
buildSessionContext().messages = [ SystemMessage(C1.systemMessage), // 有系统状态时 CompactionSummary(C1), User U4, Assistant A6, User U5, Assistant A7 ]10.3 AgentState 在这里切换到压缩后的工作集
Section titled “10.3 AgentState 在这里切换到压缩后的工作集”this._refreshFinalizedContext(); // 内部 buildSessionProjection() 并更新消息来源映射现在四个状态是:
fileEntries = 完整所有 entry,包括旧历史、JWT 兄弟分支和 C1
leafId = C1
current branch = 完整 cookie root-to-C1 path
AgentState.messages = [SystemMessage(若有), CompactionSummary(C1), U4, A6, U5, A7]这是“保留完整历史”和“限制模型工作 Context”同时成立的准确位置。
10.4 为什么 C1 被移动到 Context 首位
Section titled “10.4 为什么 C1 被移动到 Context 首位”C1 在 JSONL 中位于 A7 后,但 buildContextEntries() 把它放在输出首位:
summary of old history → retained recent messages → post-compaction new messages如果保持物理顺序:
U4 → A6 → U5 → A7 → summary模型会先看到最近工作的后半段,最后才得到旧背景。摘要前置可以理解为先交代背景、再展示保留消息;这是工程解释,不是 Provider 的通用协议要求。
10.5 Run 结束后的 threshold 压缩为什么不重放 A7
Section titled “10.5 Run 结束后的 threshold 压缩为什么不重放 A7”本次 reason 是 threshold,A7 已经成功:
willRetry = false所以 _runAutoCompaction() 只重建状态,不调用 agent.continue() 重做 A7。
在这条 Run 后路径中,若 Compaction 期间有 steering/follow-up/custom 消息进入 Agent 队列:
return agent.hasQueuedMessages()外层 _runAgentPrompt() 会执行 agent.continue(),处理排队的新消息。
同 Run 内入口不同:_compactBeforeNextAssistantResponse() 不用该返回值启动新 Run,而是取准备后的 AgentState 消息,交给已有 Loop 的下一次请求。
十一、第二次 Compaction 如何消费第一次的结果
Section titled “十一、第二次 Compaction 如何消费第一次的结果”先纠正一个很容易产生的错觉:第一次压缩后,JSONL 的物理顺序和模型 Context 的逻辑顺序 已经不同。
JSONL 物理顺序(省略更早的消息) ... → U4 → A6 → U5 → A7 → C1
模型 Context 投影 C1.summary → U4 → A6 → U5 → A7C1 在文件中始终追加在 A7 后面。只是 buildContextEntries() 为了让模型先看到背景摘要,
把 C1 投影到了保留消息前面。
继续对话后:
JSONL 物理顺序(省略更早的消息) ... → U4 → A6 → U5 → A7 → C1 → U8 → A9 → U10 → A11
压缩前的模型 Context C1.summary → U4 → A6 → U5 → A7 → U8 → A9 → U10 → A11当 Context 再次增长时:
prepareCompaction(pathEntries) → buildSessionProjection(pathEntries) → 在投影中找到贡献消息的 C1(通常 index=0) → previousSummary = C1.summary → boundaryStart = prevCompactionIndex + 1(投影已经选出 U4 起的尾部)为什么从 U4 开始,不从 C1 开始?
第一次压缩后:
C1.summary 只总结了 U4 之前的历史
U4、A6、U5、A7 当时原样保留,没有进入 C1.summary随着对话继续,这些保留消息也会变旧。第二次摘要必须把它们纳入:
previousSummary + 从 U4 开始、现在落入旧区的消息 → 更新后的 summary但“从 U4 开始”只是把 U4 设为本轮最早可处理边界,不代表 U4 之后全部进入摘要。 当前可处理范围实际是:
U4 → A6 → U5 → A7 → U8 → A9 → U10 → A11↑ ↑boundaryStart boundaryEndfindProjectedCutPoint() 仍从最新的 A11 向前累计 keepRecentTokens。假设这一次选出的新切点是 U8:
本次变旧、需要并入摘要 = U4 → A6 → U5 → A7
本次继续原样保留 = U8 → A9 → U10 → A11C1 在原始路径的物理位置位于两段之间;在规范投影里它已经前置,不作为普通对话重复总结:
getMessagesFromProjectedEntryForCompaction(C1) → []它的摘要已经通过另一个字段传入:
previousSummary = C1.summary因此第二次摘要模型接收的语义是:
已有 checkpoint C1.summary
第一次保留、现在已经变旧的消息 U4 → A6 → U5 → A7
要求 把二者合并更新为 C2.summary生成 C2 后,新的模型 Context 近似:
CompactionSummary(C2) → U8 → A9 → U10 → A11后追加的 U8~A11 并没有丢失;它们参与切点计算,因为足够新而继续原样保留。随着未来消息 继续增长,它们也可能在第三次 Compaction 时变成待总结区。
如果 U4 之后的全部消息仍落在新的 keepRecentTokens 内:
messagesToSummarize = []turnPrefixMessages = []prepareCompaction() = undefinedPi 不会为了“已经达到阈值”而重复生成一份没有释放空间的摘要。
11.1 保留区里的旧 CompactionEntry 不再贡献旧摘要
Section titled “11.1 保留区里的旧 CompactionEntry 不再贡献旧摘要”新版必须区分 entry 区间与消息投影。例如:
原始路径:U1 → A1 → U2 → A2 → C1 → U3 → A3 → C2C2.firstKeptEntryId = U2context entries = [C2, U2, A2, C1, U3, A3]虽然 C1 的原始记录还在保留区,buildSessionProjection() 对 index > 0 的 compaction entry 返回空消息。于是只有最新 C2 贡献系统状态与摘要,C1 不会再被转换成第二份 CompactionSummaryMessage。
sourceEntry.type === "compaction" && index > 0 ? [] : projectContextEntry(sourceEntry, edits.get(sourceEntry.id));prepareCompaction() 同样消费这个规范投影,找到贡献消息的最新 checkpoint,以其 summary 作为 previousSummary,之后才处理已选出的尾部。旧摘要不再作为普通对话重复总结。
另一个边界是输出预算。普通 history summary 的单次生成上限取:
min(floor(0.8 * reserveTokens), model.maxTokens)其中 model.maxTokens 不大于零时,实现视为无该项上限。split-turn 可能分别生成 history 和 prefix 两段,再加文件清单;单次调用上限不是最终合并 summary 的总上限。
如果摘要请求失败、没有可压缩区间,或压缩后原任务再次 overflow,恢复仍可能失败。_overflowRecoveryAttempted 约束连续恢复尝试,重置条件见第十二节;它不保证任意长度的会话都能恢复。
11.2 刚压缩后 token usage 为什么暂时 unknown
Section titled “11.2 刚压缩后 token usage 为什么暂时 unknown”保留下来的 A6/A7 仍携带它们生成时的 usage,而那个 usage 统计的是压缩前大 Context。 它不能代表 C1 后的新工作集。
因此:
最新 Compaction 后没有新的成功 Assistant → getContextUsage() → { tokens: null, percent: null }
C1 后产生非 error、非 aborted 且 usage > 0 的 Assistant → 获得新的 Provider usage anchor → Context usage 再次可计算_checkCompaction() 也会排除 C1 之前的 stale Assistant usage,避免刚压缩完又立即触发。
十二、同一条链遇到 overflow 时如何自动恢复
Section titled “十二、同一条链遇到 overflow 时如何自动恢复”现在模拟下一次模型调用没有成功,而是返回:
E1 = AssistantMessage( stopReason = "error", errorMessage = "prompt is too long")12.1 E1 先走普通 message_end,按正常路径追加 Session
Section titled “12.1 E1 先走普通 message_end,按正常路径追加 Session”前面第二节的持久化链照常发生:
Agent emits message_end(E1) → AgentSession._handleAgentEvent() → sessionManager.appendMessage(E1) → E1.parentId = current leaf → leafId = E1此时:
fileEntries 包含 E1AgentState.messages 末尾也是 E1Pi 没有因为它是错误就跳过 append;是否成功落盘仍取决于持久化模式及写入结果。
12.2 overflow 被明确排除出普通 retry
Section titled “12.2 overflow 被明确排除出普通 retry”Agent Run 结束:
_handlePostAgentRun() → _isRetryableError(E1)_isRetryableError() 首先:
isContextOverflow(E1) → true → 普通 retry 返回 false原因是相同 Context 原样重试仍会 overflow,必须先缩短 Context。
12.3 _checkCompaction(E1) 决定能否恢复
Section titled “12.3 _checkCompaction(E1) 决定能否恢复”它验证:
auto-compaction enabledE1 不是应跳过的 abortedE1 provider/model 与当前 model 相同E1 不早于最新 CompactionisContextOverflow(E1) = true然后:
willRetry = E1.stopReason !== "stop" = true同一用户任务还没有答案,所以压缩成功后需要继续。
12.4 排除失败尝试:追加 context_edit,而不是临时删数组尾部
Section titled “12.4 排除失败尝试:追加 context_edit,而不是临时删数组尾部”失败 E1 已经通过 message_end 保存。恢复要让模型从失败前的输入继续,不能把它当作完成的答案;agent.continue() 也不能从末尾 Assistant 直接继续。新版用来源 ID 持久化这项决定:
// _checkCompaction() 的恢复分支this._overflowRecoveryAttempted = true;this._omitRecoveryAttempt(assistantMessage, toolResults);return await this._runAutoCompaction("overflow", willRetry);
// _omitRecoveryAttempt() 缩写,省略找不到来源的错误检查和事件for (const message of [assistantMessage, ...toolResults]) { const targetId = this._findPersistedMessageEntryId(message); if (targetId) this.sessionManager.appendContextEdit(targetId, null);}this._refreshFinalizedContext();事实历史:... → E1 → context_edit(targetId=E1, replacement=null)模型投影:...(不再包含 E1)对于截断响应,还会排除这次尝试对应的 ToolResults,避免只留下失去调用方的结果。普通 retry 也调用 _omitRecoveryAttempt(message)。原始失败事实仍可审计,但排除决定本身也已持久化。
12.5 为什么重建后不会又把 E1 带回来
Section titled “12.5 为什么重建后不会又把 E1 带回来”getBranch(包含 E1 与 edit) → buildSessionProjection(应用 edit) → prepareCompaction(只总结投影后的对话) → compact → appendCompaction → _refreshFinalizedContext → agent.continue旧版“压缩前 slice 一次、重建后再 slice 一次”的说明已不适用。新版靠规范投影保持恢复前后与重启后的排除一致性。Provider 对 error 的临时过滤仍是另一层机制,不能替代持久化编辑;尤其 length 响应不能靠错误过滤处理。
12.6 返回值把控制权交回 _runAgentPrompt()
Section titled “12.6 返回值把控制权交回 _runAgentPrompt()”压缩成功且 willRetry = true:
_runAutoCompaction() → true_checkCompaction() → true_handlePostAgentRun()→ true最外层代码:
while (await this._handlePostAgentRun()) { await this.agent.continue();}于是:
agent.continue() → 从压缩后的 AgentState.messages 创建新 Context → 不追加一条新的 UserMessage → 重新完成尚未成功的原任务从用户视角看,这是一条“本轮没有完成,但系统尝试整理记忆后继续”的恢复路径:
当前 Context 放不下 → 本次失败仍进入完整历史,便于审计 → 从下一次模型输入中移除这条失败结果 → 整理旧内容,缩短工作 Context → 不要求用户重复输入,自动继续原任务一次12.7 为什么连续失败只允许一次 compact-and-retry
Section titled “12.7 为什么连续失败只允许一次 compact-and-retry”第一次 overflow:
_overflowRecoveryAttempted = true如果 agent.continue() 后再次 overflow:
不再压缩重试 → emit compaction_end(error) → 提示缩减 Context 或换更大窗口模型新 UserMessage 的 message_start 会重置标记;Assistant 的 message_end 只有在 stopReason 既不是 error 也不是 length 时才重置。“一次”指两次重置之间的连续恢复窗口,不是整个 Session 或整个用户任务永久只能尝试一次。
压缩没有“总 token 严格递减到可用窗口”的数学保证:系统提示和工具 schema 不作为对话摘要缩小,最近消息保留区可能本身过大,摘要还有输出预算与估算误差。下一次继续也可能再次增长。因此需要限制连续恢复,而不能只相信“再压缩一次一定更小”。
否则可能形成:
overflow → summary 仍过大 → retry overflow → 再 summary → 无限循环如果 Assistant 实际已经 stopReason = stop,只是上报 usage 超过配置窗口,Pi 可以压缩,
但 willRetry = false:答案已完成,不能从 Assistant 末尾重放同一个 Turn。
12.8 可恢复的 length 也进入恢复路径
Section titled “12.8 可恢复的 length 也进入恢复路径”_checkCompaction() 还检查 isRecoverableLength(assistant, model.maxTokens):输出因 length 停止,但实际输出低于模型原本期望的输出上限,具体条件由该 helper 定义。
它复用 reason 为 overflow 的恢复、连续 compact-and-retry 防护,以及通过 context_edit 排除 length Assistant 与其工具结果的逻辑。这不等同于明确的输入过长报错,也不同于摘要调用自身被截断:后者拒绝保存 checkpoint。
十三、Manual、Threshold、Overflow 的入口与继续方式
Section titled “十三、Manual、Threshold、Overflow 的入口与继续方式”到这里可以把三条入口放回同一张图:
| 原因 | 进入点 | 压缩前行为 | 成功后 |
|---|---|---|---|
| manual | AgentSession.compact() |
await abort,等待运行 idle | 重建状态,不自动继续 |
| threshold,Run 后 | _checkCompaction() |
检查 usage 或估算值 | 重建状态;有队列可另起 Run 继续 |
| threshold,Run 内 | _compactBeforeNextAssistantResponse() |
下一 Turn 前检查累积 Context | 刷新已有 Loop Context,继续下一请求 |
| 新输入前 | prompt() → _checkCompaction(lastAssistant, false) |
处理已有响应的压缩需求 | 随后加入新输入,不在这里 continue 旧任务 |
| overflow / recoverable length | _checkCompaction() |
保留原记录,追加 context_edit 排除这次尝试 | 成功后外层 continue;已完成的 stop 响应不重试 |
三者最终汇合:
getBranch() → prepareCompaction() → session_before_compact → compact() 或 Extension 自定义结果 → appendCompaction() → buildSessionContext() → replace AgentState.messages13.1 Manual Compaction 的停止与取消边界
Section titled “13.1 Manual Compaction 的停止与取消边界”AgentSession.compact(customInstructions?):
await abort(),等待 AgentSession idle → 创建 manual AbortController → emit compaction_start(manual) → prepare / hook / compact / append / rebuild → emit compaction_end(manual) → 清理 manual AbortController特殊结果:
最后 entry 已经是 compaction → Already compacted
没有可总结消息 → Nothing to compact
abortCompaction() → 中断摘要请求或 retry backoff → 不 append CompactionEntry这里没有断开再重连 Agent 订阅的步骤,也不是数据库事务。正常取消检查在 append 前;append 后的事件或写入错误不会统一回滚状态。
13.2 Extension 能替换哪一步
Section titled “13.2 Extension 能替换哪一步”session_before_compact 收到:
preparationbranchEntriescustomInstructionsreasonwillRetrysignal它可以:
cancel: true;- 提供自定义
summary / firstKeptEntryId / tokensBefore / details / usage; - 不处理,让默认
compact()继续。
无论摘要来自默认实现还是 Extension,后面的:
appendCompaction() → buildSessionContext() → replace AgentState都由 AgentSession 统一完成。
十四、把最终 JSONL、树和 Context 一次推导完
Section titled “十四、把最终 JSONL、树和 Context 一次推导完”下面用精简 JSONL 汇总贯穿案例。真实 AssistantMessage 还包含 api、provider、model、usage 等字段,这里只省略与树推导无关的内容。
{"type":"session","version":3,"id":"S1","timestamp":"2026-07-29T09:00:00Z","cwd":"/repo"}{"type":"message","id":"U1","parentId":null,"timestamp":"2026-07-29T09:00:01Z","message":{"role":"user","content":"检查认证实现","timestamp":1}}{"type":"message","id":"A1","parentId":"U1","timestamp":"2026-07-29T09:00:02Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call-1","name":"read","arguments":{"path":"auth.ts"}}]}}{"type":"message","id":"T1","parentId":"A1","timestamp":"2026-07-29T09:00:03Z","message":{"role":"toolResult","toolCallId":"call-1","toolName":"read","content":[{"type":"text","text":"..."}],"isError":false}}{"type":"message","id":"A2","parentId":"T1","timestamp":"2026-07-29T09:00:04Z","message":{"role":"assistant","content":[{"type":"text","text":"认证逻辑已定位"}]}}{"type":"message","id":"U2","parentId":"A2","timestamp":"2026-07-29T09:01:00Z","message":{"role":"user","content":"改成 JWT","timestamp":2}}{"type":"message","id":"A3","parentId":"U2","timestamp":"2026-07-29T09:01:01Z","message":{"role":"assistant","content":[{"type":"text","text":"JWT 方案已完成"}]}}{"type":"branch_summary","id":"BS1","parentId":"A2","timestamp":"2026-07-29T09:02:00Z","fromId":"A2","summary":"离开的 JWT 路径已完成认证改造。"}{"type":"message","id":"U2B","parentId":"BS1","timestamp":"2026-07-29T09:02:01Z","message":{"role":"user","content":"改成 session cookie","timestamp":3}}{"type":"message","id":"A4","parentId":"U2B","timestamp":"2026-07-29T09:02:02Z","message":{"role":"assistant","content":[{"type":"text","text":"cookie 方案已完成"}]}}{"type":"message","id":"U3","parentId":"A4","timestamp":"2026-07-29T09:03:00Z","message":{"role":"user","content":"补充测试","timestamp":4}}{"type":"message","id":"A5","parentId":"U3","timestamp":"2026-07-29T09:03:01Z","message":{"role":"assistant","content":[{"type":"text","text":"测试已补充"}]}}{"type":"message","id":"U4","parentId":"A5","timestamp":"2026-07-29T09:04:00Z","message":{"role":"user","content":"检查边界条件","timestamp":5}}{"type":"message","id":"A6","parentId":"U4","timestamp":"2026-07-29T09:04:01Z","message":{"role":"assistant","content":[{"type":"text","text":"边界条件已处理"}]}}{"type":"compaction","id":"C1","parentId":"A6","timestamp":"2026-07-29T09:05:00Z","summary":"目标是完成认证改造;当前采用 session cookie;测试已补充。","firstKeptEntryId":"U4","tokensBefore":115000}14.1 完整树
Section titled “14.1 完整树”U1 → A1 → T1 → A2 ┬→ U2 → A3 \ BS1 → U2B → A4 → U3 → A5 → U4 → A6 → C114.2 getBranch(C1)
Section titled “14.2 getBranch(C1)”只沿 parentId 回溯:
[U1, A1, T1, A2, BS1, U2B, A4, U3, A5, U4, A6, C1]JWT 分支 [U2, A3] 不在当前 branch。
14.3 buildContextEntries(C1)
Section titled “14.3 buildContextEntries(C1)”最新 Compaction 是 C1,firstKeptEntryId = U4:
[C1, U4, A6]14.4 buildSessionContext(C1)
Section titled “14.4 buildSessionContext(C1)”messages = [ CompactionSummary("目标是完成认证改造..."), User("检查边界条件"), Assistant("边界条件已处理") ]
model / thinkingLevel = 从完整 current branch 恢复14.5 下一次 Provider 最终看到什么
Section titled “14.5 下一次 Provider 最终看到什么”阶段 5 的链继续接手:
AgentState.messages → Extension context hook → convertToLlm() → CompactionSummary 转为带说明前缀的 UserMessage → transformMessages() → Provider payload因此模型看到“旧历史摘要 + 最近原始消息”,而不是完整 JSONL,也不是整棵树。
十五、Session 格式和恢复边界
Section titled “十五、Session 格式和恢复边界”当前还包含 context_edit 与 usage:前者修改模型投影,后者仅记录模型归属的用量。CompactionEntry 可携带 systemMessage;appendCompaction(summary, null, ...) 会用新 checkpoint 自身 ID 表示不保留此前尾部。常规自动压缩仍使用 prepareCompaction 返回的 firstKeptEntryId。
主调用链已经闭合,再回头整理格式不会打断理解。
15.1 Header 和普通 entry 的 ID 不属于同一层
Section titled “15.1 Header 和普通 entry 的 ID 不属于同一层”interface SessionHeader { type: "session"; version?: number; id: string; timestamp: string; cwd: string; parentSession?: string;}
interface SessionEntryBase { type: string; id: string; parentId: string | null; timestamp: string;}区别:
Header.parentSession → 新 Session 文件来源于哪个旧文件
Entry.parentId → 当前文件中这个 entry 的父节点15.2 Entry 类型在 Context 中的去向
Section titled “15.2 Entry 类型在 Context 中的去向”| Entry | Session 用途 | buildSessionContext() |
|---|---|---|
message |
对话事实 | 原 AgentMessage |
model_change |
配置历史 | 恢复 model,不生成消息 |
thinking_level_change |
配置历史 | 恢复 thinking,不生成消息 |
compaction |
Context checkpoint | CompactionSummaryMessage |
branch_summary |
离开路径的语义 | BranchSummaryMessage |
custom_message |
Extension 模型上下文 | CustomMessage |
custom |
Extension 私有状态 | 忽略 |
label |
书签 | 忽略 |
session_info |
显示名 | 忽略 |
15.3 JSONL 为什么合适
Section titled “15.3 JSONL 为什么合适”它带来的直接工程收益:
- 普通消息在首次 flush 后通常逐条追加 JSON 行;
- 分支只需要改变
parentId,不用复制旧数组; - 大 Session 可以逐行发现和统计;
- 单行损坏可以跳过;
- 旧版本可以在载入后迁移。
代价:
- 文件只增长,不因 Compaction 变小;
- 当前 leaf 没有独立持久化;
- label/name 的修改要通过新 entry 表达;
- 手工编辑可能产生 orphan 或失效的
firstKeptEntryId。
防御行为:
非法 JSON 行 → 跳过
第一个有效 entry 不是合法 Header → 文件整体无效
parentId 父节点缺失 → getTree() 把 orphan 当额外 root → buildSessionPath() 在断链处停止
firstKeptEntryId 不在当前 path → 保留 Compaction Summary 和它之后的新 entries → 不猜测旧历史边界15.4 leaf 游标的持久化限制
Section titled “15.4 leaf 游标的持久化限制”branch(id) 本身只执行:
this.leafId = id;如果移动 leaf 后立即退出、没有追加消息、Branch Summary 或其他 entry,JSONL 不知道这次 游标移动。重载仍把最后物理 entry 作为 leaf。
通常 /tree 后会:
- 追加 Branch Summary;或
- 发送修改后的消息。
新 entry 的 parentId 会自然固化新路径。
十六、当前 CLI 的 checkpoint 边界
Section titled “十六、当前 CLI 的 checkpoint 边界”本章只追踪 coding-agent 的 SessionManager。当前 checkpoint 的关键结构是:
{ type: "compaction", summary, firstKeptEntryId, tokensBefore, systemMessage, // 当前 Prompt 与工具声明的重放快照(存在时) details, usage }firstKeptEntryId 仍指向原历史中的保留边界,systemMessage 保存系统状态,buildSessionProjection() 应用上下文编辑。这不是把整个 JSONL 改写成一段摘要。
packages/agent/src/harness 和 packages/durable 的存储、恢复代码已经继续演进。不要把另一路的 retainedTail 或 operation 恢复语义套进当前 CLI;后续专章需重新沿其实际入口核对。
十七、按本章运行顺序回查源码
Section titled “十七、按本章运行顺序回查源码”源码回查不是重新逐行精读,而是验证文档中的数据是否真的在相邻函数间传递。
第一遍:U1 如何落盘并恢复
Section titled “第一遍:U1 如何落盘并恢复”-
agent-session.ts:_handleAgentEvent()
看message_end如何按 role 选择 append 方法。 -
session-manager.ts:appendMessage()
看 AgentMessage 怎样得到id/parentId/timestamp。 -
session-manager.ts:_appendEntry()
看fileEntries/byId/leafId如何同时更新。 -
session-manager.ts:_persist()
看只有初始化记录时为何延迟,而 U1 到达就落盘。 -
session-manager.ts:_setSessionFile()与_buildIndex()
看 JSONL 重载后 leaf 如何恢复。 -
session-manager.ts:buildSessionContext()
看恢复结果怎样成为AgentState.messages。
这一遍只跟踪:
U1 → A1 → T1 → A2第二遍:U2 如何变成两条分支
Section titled “第二遍:U2 如何变成两条分支”agent-session.ts:navigateTree()branch-summarization.ts:collectEntriesForBranchSummary()branch-summarization.ts:prepareBranchEntries()branch-summarization.ts:generateBranchSummary()session-manager.ts:branchWithSummary()- 回到
navigateTree()末尾的buildSessionContext() - 再回
_handleAgentEvent()看 U2B 如何挂到 BS1
这一遍只跟踪:
A2 ┬→ U2 → A3 └→ BS1 → U2B → A4第三遍:当前 branch 如何压缩
Section titled “第三遍:当前 branch 如何压缩”agent-session.ts:_runAgentPrompt()agent-session.ts:_handlePostAgentRun()agent-session.ts:_checkCompaction()agent-session.ts:_runAutoCompaction()compaction.ts:prepareCompaction()compaction.ts:findProjectedCutPoint()compaction.ts:compact()- 回到
_runAutoCompaction()的appendCompaction() session-manager.ts:buildContextEntries()- 回到
_runAutoCompaction()覆盖agent.state.messages
另外对照 _compactBeforeNextAssistantResponse() 与 prompt() 的调用位置,区分同 Run 内与新输入前的压缩入口。
这一遍只跟踪:
完整 branch → messagesToSummarize / retained → CompactionResult → C1 → [C1 summary, retained messages]第四遍:E1 如何触发恢复
Section titled “第四遍:E1 如何触发恢复”_handleAgentEvent(message_end E1)_handlePostAgentRun()_isRetryableError()_checkCompaction(E1)_runAutoCompaction("overflow", true)_runAgentPrompt()中的whileagent.continue()
这一遍只跟踪:
E1 写 Session → 追加 context_edit 排除 E1,并重建 AgentState → compact → rebuild → continuerg -n "_handleAgentEvent|appendMessage|_appendEntry|_persist" \ packages/coding-agent/src/core
rg -n "navigateTree|collectEntriesForBranchSummary|branchWithSummary" \ packages/coding-agent/src/core
rg -n "_handlePostAgentRun|_checkCompaction|_runAutoCompaction" \ packages/coding-agent/src/core/agent-session.ts
rg -n "prepareCompaction|findProjectedCutPoint|completeSummarization|async function compact" \ packages/coding-agent/src/core/compaction
rg -n "buildSessionPath|buildContextEntries|buildSessionContext" \ packages/coding-agent/src/core/session-manager.ts本次复核的关键源码位置
Section titled “本次复核的关键源码位置”- 内存更新与首次写入
- 尾行换行修复
- 保留区与旧摘要投影
- 同 Run 内压缩
- 手动压缩
- overflow 与 length 恢复
- 切点与 preparation
- 截断摘要拒绝
- split-turn 合并条件
- fork 目标与持久化分支
十八、用测试验证每一个交接点
Section titled “十八、用测试验证每一个交接点”不要逐条背断言。先按本章案例预测四个状态,再看测试是否相同。
| 交接点 | 测试 | 要验证的结果 |
|---|---|---|
| append → parentId/leaf | session-manager/tree-traversal.test.ts |
每次 append 成为旧 leaf 的 child |
| leaf → current branch | 同上 | getBranch() 只返回 root-to-leaf |
| branch → Context | session-manager/build-context.test.ts |
A/B leaf 得到不同 messages |
| navigate → summary target | agent-session-tree-navigation.test.ts |
Summary 挂到目标继续点 |
| navigate abort → 原状态 | 同上 | entries 和 leaf 不变 |
| fork → 新文件 path | agent-session-branching.test.ts |
只复制选中点以前的 path |
| clone → current leaf | interactive-mode-clone-command.test.ts |
fork(leaf, { position: "at" }) |
| path → cut point | compaction.test.ts |
ToolResult 不成为首条保留 entry |
| previous C1 → second preparation | 同上 | 旧保留区变旧后重新进入 summary |
| C1 → compacted Context | session-manager/build-context.test.ts |
最新 summary 在首位 |
| stale usage → no repeat | agent-session-auto-compaction-queue.test.ts |
C1 前 usage 不再次触发 |
| overflow → only once | 同上 | 第二次 overflow 不再 compact |
| summary retry | suite/regressions/6647-compaction-retries-transient-stream-drop.test.ts |
terminated 重试,quota 不重试 |
| 同 Run 工具输出后压缩 | suite/agent-session-compaction.test.ts |
下一 Assistant 请求前刷新 Context |
| 截断摘要拒绝落盘 | suite/regressions/7048-compaction-truncated-summary.test.ts |
length 摘要不生成 checkpoint |
上表路径相对 packages/coding-agent/test/。本次未运行完整 Pi 测试套件或真实 Provider E2E;阅读测试可核对预期,实际验证范围见审计记录。
十九、理解检查
Section titled “十九、理解检查”沿贯穿案例回答,不要只背定义:
- U1 的
parentId为什么是 null,A1 的为什么是 U1? _handleAgentEvent()与SessionManager谁负责识别 Agent 消息,谁负责构造 entry?- U1 已进入
fileEntries时,磁盘文件为什么可能还不存在? - 重启后
_buildIndex()怎样得到 leaf? buildSessionPath()和buildContextEntries()分别选择什么?- model/thinking 为什么从完整 path 恢复,而不是从压缩后的 entries 恢复?
- 选择 U2 重新编辑时,newLeaf 为什么是 A2?
- BS1 为什么挂到 A2,不挂到 A3?
- U2/A3 为什么仍在 JSONL,却不在 cookie 分支的 AgentState?
/fork U2和/tree U2对文件、runtime 和编辑器分别有什么影响?_handlePostAgentRun()为什么先处理普通 retry,再检查 Compaction?reserveTokens和keepRecentTokens分别在哪个函数阶段发挥作用?prepareCompaction()为什么只接收 current branch,不接收全树?- 为什么 ToolResult 不能作为
firstKeptEntryId? - split turn 的三段分别是什么?
compact()为什么不直接写 Session?- C1 追加在 A7 后,为什么 Context 中 C1 summary 出现在 A7 前?
- 第二次 Compaction 为什么从 C1 的
firstKeptEntryId开始处理? - E1 为什么先持久化,再追加 context_edit 排除?
- overflow 为什么不能使用普通 retry?
_runAutoCompaction()的true怎样最终变成agent.continue()?- 为什么 compact-and-retry 只能尝试一次?
能用同一组 entry 从头推导到尾,才算真正理解阶段 6。
二十、章末功能总览
Section titled “二十、章末功能总览”前面从源码解释了每个交接点。这里暂时忘掉类名和函数名,只回答四个功能问题:
- 一次 Session 如何从开始一直工作到很长;
- 完整历史、当前路线和模型记忆是什么关系;
- 返回过去改走新路线时,旧路线如何处理;
- Context 不够时,系统怎样继续完成原任务。
20.1 一次 Session 的完整生命周期
Section titled “20.1 一次 Session 的完整生命周期”flowchart TB
Start["用户开始一个任务"] --> Talk["用户与 Pi 持续交流、调用工具并修改项目"]
Talk --> Save["每一步都进入完整 Session 历史"]
Save --> Situation{"接下来发生什么?"}
Situation -->|"正常继续"| Work["沿当前路线继续工作"]
Situation -->|"想推翻之前的选择"| Back["回到过去的某个位置"]
Situation -->|"对话越来越长"| Long["整理较旧内容,保留最近交流"]
Situation -->|"关闭后重新打开"| Reopen["从最后物理 entry 恢复路线"]
Back --> Choice{"如何继续?"}
Choice -->|"仍在当前 Session 中探索"| Same["保留旧路线,同时建立新路线"]
Choice -->|"希望独立成另一个任务"| New["复制选中的历史路线,建立新 Session"]
Same --> Working["形成当前工作视图"]
New --> Working
Long --> Memory["得到更新后的滚动摘要"] --> Working
Reopen --> Working
Work --> Working
Working["当前工作视图<br/>有用的旧结论 + 最近交流"] --> Reply["Pi 继续处理任务"]
Reply --> Save
Save -->|"用户结束本次工作"| End["Session 留在磁盘,之后仍可恢复"]这张图的核心是:Session 始终保存完整经历,但 Pi 每一刻只沿一条当前路线、使用一份适合模型阅读的工作视图。
20.2 完整历史、当前路线和模型记忆
Section titled “20.2 完整历史、当前路线和模型记忆”flowchart LR
History["完整 Session 历史<br/>所有交流、所有分支、压缩记录和失败记录"]
Route["当前选择的路线<br/>从任务起点走到当前位置"]
View["模型当前看到的内容<br/>最新滚动摘要 + 最近交流"]
Model["模型继续回答或调用工具"]
History -->|"选择其中一条路线"| Route
Route -->|"内容过长时整理旧部分"| View
View --> Model
Model -->|"新的交流继续写回"| History
Branch["切换分支"] -.->|"改变选中哪条路线<br/>不删除完整历史"| Route
Compact["执行压缩"] -.->|"改变路线的表达方式<br/>不改变所选路线"| View因此:
- 分支解决“现在要沿哪段历史继续”;
- Compaction 解决“这段历史怎样放进有限 Context”;
- 两者都不会把完整 Session 当场删掉或覆盖。
20.3 返回过去后,旧分支和 Branch Summary 的关系
Section titled “20.3 返回过去后,旧分支和 Branch Summary 的关系”flowchart TB
Shared["两条路线共有的历史<br/>已经读文件、确认问题和形成共识"]
Old["旧路线<br/>例如已经完成 JWT 方案"]
Return["用户回到分叉点<br/>决定改做 Cookie 方案"]
Summary["可选的旧分支摘要<br/>只提炼旧路线新增且仍有参考价值的结论"]
New["新路线<br/>继续实现 Cookie 方案"]
Shared --> Old
Old --> Return
Shared --> Return
Return --> New
Old -.->|"原样保留在完整历史中"| Archive["旧路线仍可再次进入"]
Old -.->|"如果用户选择携带"| Summary
Summary -.->|"作为背景参考,不代表继续执行旧方案"| New这里要避免两个误解:
- 新路线不需要重新携带共有历史,因为它本来就在分叉点之前;
- Branch Summary 不是把旧路线强行带回来,而是用户可选地保留其中仍然有用的发现。旧方案如果已经过时,可以不生成摘要。
20.4 长对话、滚动压缩和 overflow 恢复
Section titled “20.4 长对话、滚动压缩和 overflow 恢复”flowchart TB
Grow["当前路线持续增长<br/>同 Run 下一 Turn 前也可检查 threshold"] --> Result{"本轮请求结果"}
Result -->|"正常完成"| Limit{"是否接近 Context 上限?"}
Result -->|"Context overflow"| Record["失败结果保留在完整历史中<br/>但不再发送给模型"]
Limit -->|"否"| Continue["继续正常工作"]
Continue --> Grow
Limit -->|"是"| Roll["把较旧内容整理进滚动摘要<br/>最近交流保持原样"]
Roll --> View["新的工作视图<br/>最新滚动摘要 + 最近交流"]
View --> Grow
Record --> Guard{"当前连续恢复窗口是否已尝试?"}
Guard -->|"是"| Fail["停止自动恢复并报告失败"]
Guard -->|"否"| Emergency["尝试压缩旧内容"]
Emergency --> CompactOK{"压缩是否成功?"}
CompactOK -->|"否"| Fail
CompactOK -->|"是"| Retry["自动继续原任务一次<br/>由实际响应确认能否完成"]
Retry --> Grow
Previous["上一版滚动摘要"] -.->|"与后来变旧的交流一起重新整理"| Roll多次压缩旨在维护更新后的滚动摘要;旧摘要是否仍出现在工作视图中取决于保留边界,详见 11.1。
如果最新滚动摘要本身已经大到无法进一步腾出空间,自动恢复也会失败;连续失败的恢复窗口只允许一次 compact-and-retry,避免无进展的循环。
二十一、面试表达
Section titled “二十一、面试表达”30 秒版本
Section titled “30 秒版本”Pi 把 Session 保存成 append-only JSONL 树。每个 Agent message_end 由 AgentSession 转交给
SessionManager,后者用 id/parentId 挂到当前 leaf。恢复时先选 root-to-leaf branch,再由
最新 CompactionEntry 把它投影成“摘要 + 最近消息”。压缩不删除历史。遇到 overflow 时,
错误消息先持久化用于审计,再追加 context_edit 从工作投影排除;Pi 压缩当前 branch 后用
agent.continue() 自动重试一次。
2 分钟版本
Section titled “2 分钟版本”Session 有四层状态:JSONL 是完整事实,id/parentId 构成全树,leaf 选择当前 branch,
buildSessionContext() 生成模型工作消息。普通消息通过 AgentSession 的 message_end
监听器进入 SessionManager;appendMessage() 补树字段,_appendEntry() 依次更新
fileEntries/byId/leafId。/tree 只在同文件改变 leaf,必要时把离开分支总结后挂到目标继续点;
/fork 和 /clone 则抽取一条 path 到新 Session 并重建 runtime。
长 Context 由 AgentSession 在新输入前、同 Run 下一 Turn 前及 Run 结束后检查,
根据 Provider usage 或消息估算判断 threshold;prepareCompaction() 只处理当前 branch,从尾部累计
token,确定待总结区和 firstKeptEntryId。如果切开一个超长 Turn,还单独总结 Turn 前缀。
compact() 生成结构化 checkpoint,但不写文件;AgentSession 追加 CompactionEntry 后再次
调用 buildSessionContext(),将 AgentState 替换成 summary 加保留区。
Context overflow 不走普通 retry,因为同一输入必然再次失败。错误 Assistant 仍写入完整
Session,并追加 context_edit 从工作投影排除;压缩成功后外层循环调用 agent.continue()。连续失败的恢复窗口只允许一次,
新 UserMessage 或非 error、非 length 的 Assistant 会重置保护标记。
下一阶段连接
Section titled “下一阶段连接”本章已经遇到两个 Extension 生命周期入口:
session_before_tree → 取消导航或提供 Branch Summary
session_before_compact → 取消压缩或提供自定义 CompactionResult下一阶段从这里继续:
Extension 如何被发现和加载 → hook 如何注册到 Runner → hook 怎样介入 Session、Agent 和 Tool 生命周期 → Skill、Tool、Prompt Template 与 Extension 如何分工 → MCP 为什么通过 Extension 接入而不进入 Pi 核心本章的 Session entry 和 hook 执行边界,是理解阶段 7 可扩展运行时的直接前置。
新增:虚拟选择与嵌套工具怎样影响历史和压缩
Section titled “新增:虚拟选择与嵌套工具怎样影响历史和压缩”Session 的 JSONL、首条 user/assistant 落盘和 context_edit 投影机制仍成立。新增内容主要是:
- 虚拟模型选择保存在 model_change;路由状态以
pi.virtual-model-statecustom entry 随分支保存。 Assistant 则记录实际物理模型与本次 thinkingLevel,不能靠最后一条 Assistant 覆盖虚拟选择。 - 嵌套工具不逐个追加模型 ToolResult;外层 ToolResult 的 nestedCalls 保留有限调用记录,usage 汇总子调用 消耗。compaction 的文件操作提取现在同时检查这部分记录,才能识别 codemode 内部的 read/edit/write。
- 虚拟模型需要先路由、再按真实模型窗口检查阈值;摘要生成也先解析 direct 路由。上下文 token 估算来自 当前 SessionProjection 与 branch,不应把虚拟目录里未知的窗口值当成实际额度。
// compaction/utils.ts:extractFileOpsFromMessage 缩写,省略防御检查。if (message.role === "toolResult") { for (const call of message.nestedCalls?.calls ?? []) { addFileOp(call.name, call.arguments, fileOps); }} else if (message.role === "assistant") { for (const block of message.content) { if (block.type === "toolCall") addFileOp(block.name, block.arguments, fileOps); }}nestedCalls 是有大小上限的记录,不承诺记录全部外部副作用。本章仍讲 coding-agent 的 SessionManager;
新 pi-durable 的 Task/Commit/CompactionTask 是另一套运行时,详见第十章,不混用两者的恢复保证。