第八章讲了 Session 为什么是树、JSONL 为什么合适。这一章回答一个更硬的问题: 从一棵可能有分支、有压缩点的树,到底怎么算出"这次要发给 LLM 的消息列表"? 答案是两个函数:
buildContextEntries()和buildSessionContext()。
第八章从 SessionStorage / SessionRepo 接口切入——那是 agent-core 的存储抽象层。
coding-agent 在其之上提供了更高层的 SessionManager 门面,多数应用直接用它。
@startuml
skinparam backgroundColor transparent
rectangle "SessionManager\n(coding-agent)" as SM #E8F5E9 {
rectangle "buildContextEntries()" as B1
rectangle "buildSessionContext()" as B2
rectangle "appendMessage() / branch() ..." as A
}
rectangle "JSONL 文件\n(树状 entries)" as J #E3F2FD
SM --> J : 追加 / 读取
note bottom of SM
两层职责分离:
· 存储层管字节
· SessionManager 管语义
end note
@enduml本章聚焦 SessionManager,因为"如何从树重建上下文"的逻辑在这一层。
第八章列过 SessionTreeEntry 的多种类型。关键要建立的一个区分是:Entry 是磁盘上的记录,Message 是发给 LLM 的东西。两者不是一一对应。
树里的每个 Entry 都继承同一个基类:
interface SessionEntryBase {
type: string;
id: string; // 8 字符 hex
parentId: string | null; // 第一条为 null
timestamp: string; // ISO 时间戳
}而 message 类型的 Entry 里装的是一个 AgentMessage——它是个联合类型,比 pi-ai 的基础三种消息更宽:
type AgentMessage =
| UserMessage // pi-ai 基础类型
| AssistantMessage // pi-ai 基础类型
| ToolResultMessage // pi-ai 基础类型
| BashExecutionMessage // coding-agent 扩展:!命令执行
| CustomMessage // 扩展注入、参与上下文
| BranchSummaryMessage // 分支摘要
| CompactionSummaryMessage; // 压缩摘要后四种是 coding-agent 在 pi-ai 之上的扩展。比如 BashExecutionMessage 记录 ! 前缀直接执行的命令,还带一个 excludeFromContext(!! 前缀时为 true,表示不进 LLM 上下文)。
| Entry 类型 | 进 LLM 上下文? | 用途 |
|---|---|---|
message |
✅ | 正常对话消息 |
compaction |
✅ | 转成 compactionSummary(+ retainedTail) |
branch_summary |
✅ | 转成 branchSummary |
custom_message |
✅ | 扩展注入的消息 |
custom |
❌ | 扩展状态持久化,仅重载时读取 |
model_change |
❌ | 元数据,影响"用哪个模型"但不是消息 |
thinking_level_change |
❌ | 同上 |
label / session_info |
❌ | 书签、会话名等元数据 |
记住这张表——它正是下一节两个函数要做的筛选。
第一步,从当前 leaf 一路走到 root,选出这条路径上"有效"的 entries。核心难点是遇到压缩点怎么办。
@startuml
skinparam backgroundColor transparent
skinparam ActivityBackgroundColor #f8f9fa
skinparam ActivityBorderColor #dee2e6
start
:从 leaf 向 root 收集路径上所有 entries;
if (路径上有 CompactionEntry?) then (yes)
:先放入 compaction entry 本身;
if (compaction 带 retainedTail?) then (yes)
:retainedTail 是自包含检查点;
:直接纳入 compaction 之后的 entries;
note right
新版本:不必回溯
compaction 之前的旧 entry
end note
else (no, 旧格式)
:纳入 firstKeptEntryId → compaction 之间的 entries;
:再纳入 compaction 之后的 entries;
endif
else (no)
:纳入整条路径;
endif
:保留选定范围内的非消息 entry;
note right: 供交互模式渲染
stop
@enduml这就是 firstKeptEntryId 和 retainedTail 两种检查点机制的分野——第十二章会讲它们在压缩时如何被写入。这里只需知道:新版压缩把保留的尾部消息直接钉在 compaction entry 上(retainedTail),使它成为自包含检查点,重建时无需读取更早的 entry。旧格式则靠 firstKeptEntryId 指针回溯。
第二步,把上一步选出的 entry 列表转成真正发给 LLM 的消息列表,同时解析出"当前用哪个模型、什么思维级别":
@startuml
skinparam backgroundColor transparent
rectangle "buildContextEntries()\n输出的 entry 列表" as E #E3F2FD
rectangle "buildSessionContext()" as B #FFE0B2
rectangle "{ messages, model, thinkingLevel }" as O #E8F5E9
E --> B
B --> O
note bottom of B
1. 从完整路径解析 model / thinkingLevel
2. 逐个 entry 转 message:
message → 存储的 AgentMessage
compaction → compactionSummary (+ retainedTail)
branch_summary → branchSummary
custom_message → CustomMessage
custom → 不产生消息
end note
@enduml注意一个细节:model / thinkingLevel 是从"完整路径"解析的,而不是从压缩后的消息列表。因为 model_change / thinking_level_change 可能发生在被压缩掉的旧消息区间里——如果只看压缩后的消息就会丢掉这些设置。
至此,buildSessionContext() 返回的 messages 会再经过第五章讲的"最晚转换"(convertToLlm)才发给 Provider。整条链路:
树 → buildContextEntries → buildSessionContext → convertToLlm → stream()
(选路径+压缩) (entry转message) (AgentMessage转Message)
第八章介绍了 Fork 的概念。这里补齐 SessionManager 上三个容易混淆的操作:
| 操作 | 方法 | 效果 |
|---|---|---|
| Branch(树内分支) | branch(entryId) |
把 leaf 移到更早的 entry,之后追加的消息成为新子分支——同一个文件内 |
| Branch + 摘要 | branchWithSummary(entryId, summary, ...) |
同上,但在切换点写入分支摘要,保留被放弃分支的记忆 |
| Fork / Clone(跨文件) | createBranchedSession(leafId) / forkFrom(...) |
把某个分支抽取成新的 JSONL 文件,可跨项目 |
核心区别:Branch 是文件内的树导航(利用 id/parentId 就地分叉,不新建文件),Fork/Clone 才产生新会话文件。新文件的 header 会记录 parentSession 指向来源。
@startuml
skinparam backgroundColor transparent
rectangle "同一 JSONL 文件" as F1 {
rectangle "e1" as A1
rectangle "e2" as A2
rectangle "e3 (旧 leaf)" as A3
rectangle "e4 (branch 后新 leaf)" as A4 #C8E6C9
A1 --> A2
A2 --> A3
A2 --> A4 : branch(e2) 后追加
}
rectangle "新 JSONL 文件" as F2 #FFF3E0 {
rectangle "header\nparentSession→原文件" as H
rectangle "e1'..." as B1
H .. B1
}
A2 ..> F2 : createBranchedSession
@enduml按用途分组的常用方法(完整见 session-format.md):
// 静态创建
SessionManager.create(cwd, sessionDir?) // 新会话
SessionManager.open(path, sessionDir?) // 打开已有文件
SessionManager.continueRecent(cwd, sessionDir?) // 续最近的,或新建
SessionManager.inMemory(cwd?) // 不落盘(测试用)
SessionManager.forkFrom(sourcePath, targetCwd) // 跨项目 fork
// 追加(都返回新 entry 的 id)
appendMessage(message)
appendModelChange(provider, modelId)
appendThinkingLevelChange(level)
appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)
appendCustomEntry(customType, data?) // 扩展状态,不进上下文
appendCustomMessageEntry(customType, content, display, details?) // 进上下文
// 树导航
getLeafId() / getLeafEntry() / getEntry(id)
getBranch(fromId?) // 从某 entry 走到 root
getTree() / getChildren(parentId)
branch(entryId) / branchWithSummary(entryId, summary, ...)
// 上下文构建(本章主角)
buildContextEntries() // 活跃分支 entries(已应用压缩)
buildSessionContext() // { messages, thinkingLevel, model }- 能区分 Entry(磁盘记录)和 Message(发给 LLM)
- 说得出哪些 Entry 类型不进 LLM 上下文
- 理解
buildContextEntries遇到压缩点的两种处理路径 - 知道
retainedTail为什么让新版压缩成为"自包含检查点" - 明白 model/thinkingLevel 为何要从完整路径而非压缩后消息解析
- 区分文件内
branch()与跨文件fork/clone
上一章:第十章 · Provider 与认证
下一章:第十二章 · Compaction 内部机制 — 摘要到底怎么切、怎么生成