跳转到内容

Session、分支与 Compaction

内容状态
已校验
校验日期
2026/10/1

本文基于上述固定 revision,案例是用于解释状态变化的构造数据,未调用真实 Provider。摘要是有损的会话上下文,不保证保存全部事实,也不等同于跨 Session 自动检索的长期记忆。

上一篇《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
→ 压缩后自动重试

每一节都继续使用上一节产生的数据。读完后,你应该不需要逐行阅读源码,就能回答:

  1. 一条 message_end 怎样变成 JSONL 中的一行;
  2. id、parentId、leafId 怎样把线性写入变成会话树;
  3. 完整 JSONL、当前 branch 和 AgentState.messages 为什么是三份不同视图;
  4. /tree 如何移动当前路径,Branch Summary 为什么挂在目标位置;
  5. /fork 和 /clone 为什么不是同文件分支;
  6. _handlePostAgentRun() 怎样进入自动压缩;
  7. prepareCompaction() 怎样得到摘要区、Turn 前缀和保留区;
  8. CompactionEntry 追加后,旧消息为什么还在磁盘却不再发给模型;
  9. 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。

本章不把 Session 当作一组零散 API,而是始终追踪同一次工作的生命周期:

开始对话
→ 每轮交流进入完整历史
→ 用户可能回到过去改走另一条路线
→ 对话过长后,把旧内容逐步整理成摘要
→ 模型始终使用“当前路线的滚动摘要 + 最近交流”
→ 即使发生 overflow,也尽量在不丢失完整历史的前提下恢复

读完全部实现后,可以回到第二十节的功能总览图,把各段源码重新放回这条主线。

职责边界先记住:

SessionManager
只负责 entry、树、文件和 Context 投影
不调用摘要模型
compaction.ts
只负责估算、选切点和生成摘要
不写 Session 文件
AgentSession
连接 Agent 事件、SessionManager 和 Compaction
决定何时压缩、保存结果以及是否继续

下面从 U1 真正进入系统开始。

后文出现 /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 构造时订阅 Agent
agent.subscribe(event => agentSession._handleAgentEvent(event));
// ① 发起运行(2.1):产品入口创建用户消息,再交给 Agent
agentSession.prompt("检查认证实现");
// 内部:创建 UserMessage U1 → _runAgentPrompt(U1) → agent.prompt(U1)
// Loop 发出 message_end(U1);此时用户消息已加入对话,不是等模型回复。
// Agent 处理该事件:先更新 AgentState.messages,再通知上面注册的订阅者。
// ② 接住事件(2.2):把完成的普通消息交给 SessionManager
async 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_end

Session 持久化不是 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
输出目标:SessionMessageEntry

2.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 = U1
current branch = U1
AgentState.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=true
A1、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] = U1
byId[A1] = A1
byId[T1] = T1
byId[A2] = A2
leafId = 最后一个物理 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。


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”

入口:

初始数据:

oldLeafId = A3
targetId = U2

函数先验证 target,再调用:

collectEntriesForBranchSummary(sessionManager, A3, U2)

它分别取得:

old path = U1 → A1 → T1 → A2 → U2 → A3
target 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 = A3
fileEntries 没有新增 entry
AgentState.messages 没有改变

默认导航在摘要返回错误或 abort 时先退出,不推进 leaf。这是先准备、再修改路径的执行顺序,不是通用事务;Extension 副作用、之后的写入或事件失败没有统一回滚保证。

/tree 改变的是会话路径,不是代码仓库快照。离开的分支可能已经:

修改了工作区文件
运行了会影响后续判断的命令或测试
发现了隐藏约束
证明某个方案不可行

切换 Session branch 不会自动撤销这些副作用。例如旧 JWT 分支已经修改 auth.ts, 回到 A2 后,模型的对话 Context 看似回到了修改前,但磁盘上的 auth.ts 仍可能是 JWT 版本。如果完全丢掉 A3,模型就不知道当前工作区为什么与公共历史不一致。

Branch Summary 用来保存的是“离开的分支发生过什么”,不是宣布“旧方案继续有效”。 默认摘要会带有来源说明,明确这是另一条对话分支中的探索,让后续模型判断哪些内容应当:

继续复用
作为失败经验保留
主动撤销
按新方向重新实现

但它确实可能引入已经过时的信息,所以 navigateTree() 通过 options.summarize 让调用者选择:

旧分支有文件副作用、重要发现或失败经验
→ summarize = true
旧分支完全无价值,希望获得干净的新 Context
→ summarize = false
→ 只移动 leaf,不创建 BranchSummaryEntry

4.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 id
editorText = undefined

4.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 = A2
BS1.fromId = A2
leafId = BS1

此时完整树是:

共享且原样保留
U1 → A1 → T1 → A2 ┬→ U2 → A3 旧 JWT 路径
\
BS1 (new leaf) 只总结将离开的 A3

Branch 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

入口位于 agent-session-runtime.ts 的 fork():

selectedEntry = U2
position = 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 返回编辑器等待修改。

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 → A7

当前 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 = 128k
reserveTokens = 16k
contextTokens = 115k
115k > 112k
→ _runAutoCompaction("threshold", willRetry = false)

reserveTokens 用来留出下一次输出空间。真正要保留多少最近消息,由另一个配置 keepRecentTokens 决定。

这一节的输出是:

reason = threshold
willRetry = false
pathEntries = 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 = -1
previousSummary = undefined
boundaryStart = 0
boundaryEnd = 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 不能成为切点”

合法切点包括:

user
assistant
bashExecution
custom message
branch summary

ToolResult 不合法。否则可能得到:

Context 开头 = ToolResult(call_123)

而产生 call_123 的 Assistant ToolCall 已被摘要掉,Provider 会拒绝不成对的工具协议。

切在带 ToolCall 的 Assistant 前则安全,因为后续 ToolResult 会随它一起保留。

7.5 第四步:不可见 metadata 跟随相邻保留区

Section titled “7.5 第四步:不可见 metadata 跟随相邻保留区”

初步切在 U4 后,算法还会向前查看相邻 entry:

model_change
thinking_level_change
custom
label
session_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.read
fileOps.edited
fileOps.written

keepRecentTokens 是近似预算,不是硬上限;文本、图片估算及合法切点约束都会影响保留量。

这份 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 = A10
A10 不是 Turn start
→ 从 A10 向前扫描
→ 找到 U6

得到:

turnStartIndex = index(U6)
firstKeptEntryIndex = index(A10)
isSplitTurn = true
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。

假设整个会话一开始就是 U6,用户的第一项任务便产生了很长的工具执行链:

U6:完成这个复杂任务
→ A8 → T2 → A9 → T3
→ 切点
→ A10 → T4 → A11

U6 前面没有待总结的对话,所以:

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 = U4
isSplitTurn = false

9.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”

默认要求保留:

Goal
Constraints & Preferences
Progress
Done
In Progress
Blocked
Key Decisions
Next Steps
Critical Context

并尽量保留精确文件路径、函数名和错误信息。输出不是为了给人写聊天回顾,而是为了让另一个 LLM 从 checkpoint 继续工作。

9.3 文件操作不完全依赖模型记忆

Section titled “9.3 文件操作不完全依赖模型记忆”

prepareCompaction() 已经确定性地收集 file operations。摘要生成后:

computeFileLists(fileOps)
→ readFiles
→ modifiedFiles
formatFileOperations()
→ 把清单追加到 summary 文本
CompactionResult.details
= { readFiles, modifiedFiles }

清单来自已识别的工具调用及符合条件的旧摘要 details,不是文件系统审计。Bash 内部修改、未识别工具,以及调用是否真正成功,都不能仅凭清单完整判定。

所有摘要请求通过 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 把结果交给 SessionManager
sessionManager.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 = 新短 ID
C1.parentId = A7
C1.firstKeptEntryId = U4
C1.summary = 旧历史 checkpoint
leafId = 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 → A7

C1 在文件中始终追加在 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 boundaryEnd

findProjectedCutPoint() 仍从最新的 A11 向前累计 keepRecentTokens。假设这一次选出的新切点是 U8:

本次变旧、需要并入摘要
= U4 → A6 → U5 → A7
本次继续原样保留
= U8 → A9 → U10 → A11

C1 在原始路径的物理位置位于两段之间;在规范投影里它已经前置,不作为普通对话重复总结:

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() = undefined

Pi 不会为了“已经达到阈值”而重复生成一份没有释放空间的摘要。

11.1 保留区里的旧 CompactionEntry 不再贡献旧摘要

Section titled “11.1 保留区里的旧 CompactionEntry 不再贡献旧摘要”

新版必须区分 entry 区间与消息投影。例如:

原始路径:U1 → A1 → U2 → A2 → C1 → U3 → A3 → C2
C2.firstKeptEntryId = U2
context 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 包含 E1
AgentState.messages 末尾也是 E1

Pi 没有因为它是错误就跳过 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 enabled
E1 不是应跳过的 aborted
E1 provider/model 与当前 model 相同
E1 不早于最新 Compaction
isContextOverflow(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.messages

13.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 后的事件或写入错误不会统一回滚状态。

session_before_compact 收到:

preparation
branchEntries
customInstructions
reason
willRetry
signal

它可以:

  • 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}
U1 → A1 → T1 → A2 ┬→ U2 → A3
\
BS1 → U2B → A4 → U3 → A5 → U4 → A6 → C1

只沿 parentId 回溯:

[U1, A1, T1, A2, BS1, U2B, A4, U3, A5, U4, A6, C1]

JWT 分支 [U2, A3] 不在当前 branch。

最新 Compaction 是 C1,firstKeptEntryId = U4:

[C1, U4, A6]
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,也不是整棵树。


当前还包含 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 的父节点
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 显示名 忽略

它带来的直接工程收益:

  • 普通消息在首次 flush 后通常逐条追加 JSON 行;
  • 分支只需要改变 parentId,不用复制旧数组;
  • 大 Session 可以逐行发现和统计;
  • 单行损坏可以跳过;
  • 旧版本可以在载入后迁移。

代价:

  • 文件只增长,不因 Compaction 变小;
  • 当前 leaf 没有独立持久化;
  • label/name 的修改要通过新 entry 表达;
  • 手工编辑可能产生 orphan 或失效的 firstKeptEntryId。

防御行为:

非法 JSON 行
→ 跳过
第一个有效 entry 不是合法 Header
→ 文件整体无效
parentId 父节点缺失
→ getTree() 把 orphan 当额外 root
→ buildSessionPath() 在断链处停止
firstKeptEntryId 不在当前 path
→ 保留 Compaction Summary 和它之后的新 entries
→ 不猜测旧历史边界

branch(id) 本身只执行:

this.leafId = id;

如果移动 leaf 后立即退出、没有追加消息、Branch Summary 或其他 entry,JSONL 不知道这次 游标移动。重载仍把最后物理 entry 作为 leaf。

通常 /tree 后会:

  • 追加 Branch Summary;或
  • 发送修改后的消息。

新 entry 的 parentId 会自然固化新路径。


本章只追踪 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 “十七、按本章运行顺序回查源码”

源码回查不是重新逐行精读,而是验证文档中的数据是否真的在相邻函数间传递。

  1. agent-session.ts:_handleAgentEvent()
    看 message_end 如何按 role 选择 append 方法。

  2. session-manager.ts:appendMessage()
    看 AgentMessage 怎样得到 id/parentId/timestamp。

  3. session-manager.ts:_appendEntry()
    看 fileEntries/byId/leafId 如何同时更新。

  4. session-manager.ts:_persist()
    看只有初始化记录时为何延迟,而 U1 到达就落盘。

  5. session-manager.ts:_setSessionFile() 与 _buildIndex()
    看 JSONL 重载后 leaf 如何恢复。

  6. session-manager.ts:buildSessionContext()
    看恢复结果怎样成为 AgentState.messages。

这一遍只跟踪:

U1 → A1 → T1 → A2
  1. agent-session.ts:navigateTree()
  2. branch-summarization.ts:collectEntriesForBranchSummary()
  3. branch-summarization.ts:prepareBranchEntries()
  4. branch-summarization.ts:generateBranchSummary()
  5. session-manager.ts:branchWithSummary()
  6. 回到 navigateTree() 末尾的 buildSessionContext()
  7. 再回 _handleAgentEvent() 看 U2B 如何挂到 BS1

这一遍只跟踪:

A2 ┬→ U2 → A3
└→ BS1 → U2B → A4
  1. agent-session.ts:_runAgentPrompt()
  2. agent-session.ts:_handlePostAgentRun()
  3. agent-session.ts:_checkCompaction()
  4. agent-session.ts:_runAutoCompaction()
  5. compaction.ts:prepareCompaction()
  6. compaction.ts:findProjectedCutPoint()
  7. compaction.ts:compact()
  8. 回到 _runAutoCompaction() 的 appendCompaction()
  9. session-manager.ts:buildContextEntries()
  10. 回到 _runAutoCompaction() 覆盖 agent.state.messages

另外对照 _compactBeforeNextAssistantResponse() 与 prompt() 的调用位置,区分同 Run 内与新输入前的压缩入口。

这一遍只跟踪:

完整 branch
→ messagesToSummarize / retained
→ CompactionResult
→ C1
→ [C1 summary, retained messages]
  1. _handleAgentEvent(message_end E1)
  2. _handlePostAgentRun()
  3. _isRetryableError()
  4. _checkCompaction(E1)
  5. _runAutoCompaction("overflow", true)
  6. _runAgentPrompt() 中的 while
  7. agent.continue()

这一遍只跟踪:

E1 写 Session
→ 追加 context_edit 排除 E1,并重建 AgentState
→ compact
→ rebuild
→ continue
终端窗口
rg -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 “十八、用测试验证每一个交接点”

不要逐条背断言。先按本章案例预测四个状态,再看测试是否相同。

交接点 测试 要验证的结果
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;阅读测试可核对预期,实际验证范围见审计记录。


沿贯穿案例回答,不要只背定义:

  1. U1 的 parentId 为什么是 null,A1 的为什么是 U1?
  2. _handleAgentEvent() 与 SessionManager 谁负责识别 Agent 消息,谁负责构造 entry?
  3. U1 已进入 fileEntries 时,磁盘文件为什么可能还不存在?
  4. 重启后 _buildIndex() 怎样得到 leaf?
  5. buildSessionPath() 和 buildContextEntries() 分别选择什么?
  6. model/thinking 为什么从完整 path 恢复,而不是从压缩后的 entries 恢复?
  7. 选择 U2 重新编辑时,newLeaf 为什么是 A2?
  8. BS1 为什么挂到 A2,不挂到 A3?
  9. U2/A3 为什么仍在 JSONL,却不在 cookie 分支的 AgentState?
  10. /fork U2 和 /tree U2 对文件、runtime 和编辑器分别有什么影响?
  11. _handlePostAgentRun() 为什么先处理普通 retry,再检查 Compaction?
  12. reserveTokens 和 keepRecentTokens 分别在哪个函数阶段发挥作用?
  13. prepareCompaction() 为什么只接收 current branch,不接收全树?
  14. 为什么 ToolResult 不能作为 firstKeptEntryId?
  15. split turn 的三段分别是什么?
  16. compact() 为什么不直接写 Session?
  17. C1 追加在 A7 后,为什么 Context 中 C1 summary 出现在 A7 前?
  18. 第二次 Compaction 为什么从 C1 的 firstKeptEntryId 开始处理?
  19. E1 为什么先持久化,再追加 context_edit 排除?
  20. overflow 为什么不能使用普通 retry?
  21. _runAutoCompaction() 的 true 怎样最终变成 agent.continue()?
  22. 为什么 compact-and-retry 只能尝试一次?

能用同一组 entry 从头推导到尾,才算真正理解阶段 6。


前面从源码解释了每个交接点。这里暂时忘掉类名和函数名,只回答四个功能问题:

  1. 一次 Session 如何从开始一直工作到很长;
  2. 完整历史、当前路线和模型记忆是什么关系;
  3. 返回过去改走新路线时,旧路线如何处理;
  4. Context 不够时,系统怎样继续完成原任务。
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,避免无进展的循环。


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() 自动重试一次。

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 会重置保护标记。


本章已经遇到两个 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-state custom 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 是另一套运行时,详见第十章,不混用两者的恢复保证。