Pi 源码拆解(三):Session和Context管理

3 分钟阅读
·

本文是「Pi 源码拆解」系列第 3 篇。系列目录:

第 2 篇讲运行时时提到:agent_end 发出后不算 run 结束,要等所有 listener 都 settle,持久化写入也计入结算。本文讨论写入的对象,即 pi 的会话层。重点是它将消息、模型切换、思考等级和工具集变更记录为 entry,以及这一选择如何支持恢复、分支和 compaction。

消息数组无法表达的会话状态

多数 harness 的会话就是一个消息数组,外加若干旁路状态:当前用哪个模型、思考等级开到几档、激活了哪些工具,这些散在运行时对象里,持久化时各想办法。Claude Code 的会话文件基本也是消息日志,模型切换这类状态变化不在其中。

这种结构有两个问题。一是恢复麻烦:重启会话时,旁路状态要么丢了、要么要单独存取。二是历史不可信:消息数组只记录「说了什么」,不记录「在什么配置下说的」,事后想回答「这段回答是用哪个模型、什么工具集生成的」,数组里没有答案。

pi 采用的实现是将这些变化记录为 entry:会话不只保存消息列表,而是一棵 entry 树,模型切换、思考等级变更与消息平级并全部落盘。

Session 是一棵 entry 树

entry 类型一共 11 种,定义在 packages/agent/src/harness/types.ts:453-464:message、thinking_level_change、model_change、active_tools_change、compaction、branch_summary、custom、custom_message、label、session_info、leaf。每个 entry 带 idparentIdtimestamptypes.ts:375-380)。parentId 只保存父节点 ID;从任意 entry 沿它持续回溯,就得到一条从该节点到根的路径。多个 entry 复用同一个 parentId 时,整体才呈现为树。

图中的根节点是首条 user message,不是 system message。pi 将 system prompt 放在 AgentHarness.systemPrompt 中:每次 turn 通过 createTurnState() 单独解析,再作为请求的 systemPrompt 与从 Session 重建的 messages 一起传给模型(packages/agent/src/harness/agent-harness.ts:395-438)。因此,它参与每次模型调用,却不作为 message entry 写入 session 树;session 树记录的是可回放的 transcript 与状态变化。

export interface SessionTreeEntryBase {
  type: string;
  id: string;
  parentId: string | null;
  timestamp: string;
}

export interface LeafEntry extends SessionTreeEntryBase {
  type: "leaf";
  targetId: string | null;
}

pi 的 entry 树结构

Session 同时需要支持追加新记录和跳回历史位置,因此区分运行时的当前位置 leafId 与持久化的 leaf 跳转记录。

  • Session.leafId 是内存中的「当前写入位置」。它保存一个 entry ID,buildContext() 从这个 ID 沿 parentId 回溯,得到当前要发送给模型的那条分支。正常对话时它指向刚追加的普通 entry,例如最后一条 assistant message。
  • leaf entry 是写入 JSONL 的「跳转记录」。它也在旧分支上有自己的 idparentId,但 targetId 另指向要跳去的历史节点。追加它之后,Session 把内存中的 leafId 改成 targetId

以图中的节点为例:当前停在旧分支的 e5-old 时,leafId = e5-old。执行 moveTo(e4) 会先把 e6 = { type: "leaf", parentId: "e5-old", targetId: "e4" } 追加到文件,留下「从哪里跳到哪里」的记录;随后内存里的 leafId 变为 e4。此后追加 e5 时,代码将 e5.parentId 填为当前的 e4,新分支由此出现。核心效果是:跳转不修改旧 entry,后续写入从目标节点另长一条分支。

追加路径的实现正对应这两个场景:普通 entry 的 parentId 使用当前 leafId,提交后新的 id 成为 leafId;leaf entry 提交后,leafId 改为它的 targetId

private enqueueAppend<TEntry extends SessionTreeEntry>(
  createEntry: (base: Pick<SessionTreeEntry, "id" | "parentId" | "timestamp">) => TEntry,
): Promise<TEntry> {
  const commit = this.appendTail.then(async () => {
    const entry = createEntry({
      id: await this.createEntryId(),
      parentId: this.leafId,
      timestamp: new Date().toISOString(),
    });
    await this.storage.appendEntry(entry);
    this.leafId = entry.type === "leaf" ? entry.targetId : entry.id;
    return entry;
  });
  // ...
}

跳回 e4 后继续写入时,moveTo(e4) 先追加一条 leaf 记录,再由后续 entry 从 e4 长出新分支。因此 leaf entry 本身可能留在旧分支末尾,而 Session.leafId 已经指向跳转目标;后续 branch_summary 或 message 的 parentId 才真正构成新分叉。旧分支保留在同一 session 文件中,无需复制原有路径;/fork 创建独立会话文件时仍会复制选定路径。

async moveTo(entryId: string | null, summary?: Summary): Promise<string | undefined> {
  await this.setLeafId(entryId);
  if (!summary) return undefined;
  return this.appendTypedEntry((base) => ({
    ...base,
    type: "branch_summary",
    fromId: entryId ?? "root",
    summary: summary.summary,
  }));
}

并发写用一条 promise 链串行化。enqueueAppend 把每次追加接到 appendTail 的末尾(session.ts:237-255),链上同一时刻只有一个 append 在执行,parentId 的计算因此永远基于确定的 leafId,不用锁。链尾的错误处理保证后续写入可继续执行:appendTail 每次都以一个处理 reject 的 then 收尾(session.ts:250-253)。单次写盘失败只会使当前调用失败,不会使后续追加无法进入队列。

Context 的构建过程如下:buildContext()session.ts:205)先从 leaf 沿 parentId 回溯,readPathToRootOrCompaction 回溯到最近的 compaction 边界为止(packages/agent/src/harness/session/array-session-index.ts:167-189),然后把这条路径投影成消息。投影分两步:defaultContextEntryTransform 把 compaction 之前的历史折叠成摘要(session.ts:61-92),deriveSessionContextState 从头重放整条路径,扫过 thinking_level_change、model_change、active_tools_change 时更新当前值,走完得到当前的 model、thinkingLevel、activeToolNames(session.ts:41-59)。就 Session 的 context 状态而言,entry 日志是恢复和推导的依据;这是事件溯源式的处理方式。

Session 文件:目录、JSONL 与追加语义

存储后端是可插拔的 SessionStorage 接口(types.ts:559-571),契约核心是读 entry、追加、按分支查询、回溯路径。实现有两个:JSONL 文件用于真实会话,内存版用于测试(packages/agent/src/harness/session/memory-repo.ts),Session 类本身不感知差异。

默认会话目录是 ~/.pi/agent/sessions/--<cwd>--/,每个工作目录一个子目录;其中 cwd 去掉开头的路径分隔符,并将 /\\: 替换为 -jsonl-repo.ts:186-188)。单个会话的文件名为 <ISO 时间戳>_<session id>.jsonl,时间戳中的 :. 同样替换为 -jsonl-repo.ts:190-205)。因此同一项目下的会话按文件归档,而不是把所有项目的记录混在一个大日志中。

文件是 JSONL:一行一个 JSON 对象。首行是 header,负责声明文件版本和会话元数据;后续每行是一条 entry。parentSession 只在 fork 出独立文件时写入,用来指回来源文件;它不是树内节点的 parentId。

{"type":"session","version":3,"id":"01...","timestamp":"2026-06-20T15:00:00.000Z","cwd":"/work/blog"}
{"type":"message","id":"e1","parentId":null,"timestamp":"...","message":{"role":"user","content":"解释这个项目"}}
{"type":"message","id":"e2","parentId":"e1","timestamp":"...","message":{"role":"assistant","content":[...]}}
{"type":"model_change","id":"e3","parentId":"e2","timestamp":"...","provider":"...","modelId":"..."}

这里有两种顺序,不能混淆:文件顺序是 entry 实际写入 JSONL 的 append 顺序;会话顺序parentId 回溯决定。跳回历史节点时,leaf entry 仍追加在文件末尾,但新分支的 entry 以跳转目标为 parentId。所以顺序读文件看到的是一条追加日志,按 parentId 查才得到当前分支或完整的树。

创建会话或 fork 时,JSONL 后端把 header 和当时已有的 entry 一次性写入(新会话通常只有 header;jsonl-repo.ts:374-385);之后每条 entry 都通过 appendFile(JSON.stringify(entry) + "\\n") 直接追加(jsonl-repo.ts:275-294)。

有分支时如何恢复内存数据? 重启后,后端先把 JSONL 的所有非空行按文件顺序解析成 entries,校验 header、JSON 和 entry ID 的唯一性;然后一次性构造 new ArraySessionIndex(entries)jsonl-repo.ts:169-183:248-252)。ArraySessionIndex.replace() 不会按 parentId 重排文件,而是顺序扫描这份追加日志,同时构建三类内存数据:完整的 entries 数组用于树浏览,byId: Map<id, entry> 用于 O(1) 查找父节点,以及 name、label、usage/statistics 等投影(array-session-index.ts:82-99)。

这里的关键是:恢复当前分支并不需要知道「下一级节点」是什么。 parentId 只有「子 → 父」这一条单向指针不是双向链表;而一棵树在分叉点本来也没有唯一的「下一级」,例如 e3 可以同时有 e4 和 e9 两个 child。Pi 不会从根节点向下猜该选 e4 还是 e9,而是先利用追加日志末尾的状态确定这次会话的终点 leafId,再从终点沿 parentId 倒着找父节点,最后反转结果。这正好绕开了「如何找 child」的问题。

当前分支的终点在这一次顺序扫描中恢复:每读到普通 entry,就令 nextLeafId = entry.id;每读到 leaf entry,就令 nextLeafId = entry.targetId。因此文件中最后一个 entry 所给出的「普通 entry 的 id」或「leaf 的 targetId」就是恢复后的当前 leaf;readHead() 随后检查该 ID 是否真的存在(array-session-index.ts:86-105)。比如文件依次追加 e1, e2, e3, e4, e5-old, e6 = { type: "leaf", targetId: "e3" }, e7:扫描到 e6 时,终点从旧分支的 e5-old 切回 e3;继续扫 e7 后终点更新为 e7。无需保存 e3.childIds = [e4, e7],因为当前分支已由最后一次跳转和其后的追加记录确定。旧分支仍完整保存在 byId 和 entries 数组中,不会被丢弃。

真正取当前 context 时,代码才从这个 leafId 开始反复查 byId.get(current.parentId),回溯出 e7 → e3 → e2 → e1,再反转为根到叶的顺序(array-session-index.ts:167-185)。这条查询只需要向上找父节点;如果是 /tree 需要展示某节点的所有 child,才遍历完整的 entries 数组,筛出 parentId === 该节点 id 的记录(或在别的实现中临时建立这样的分组)。ArraySessionIndex 本身没有维护 childIds,因为 Session 恢复 context 的核心操作是从确定的 leaf 向上回溯,不是从根向下遍历。也就是说,恢复树是把所有 entry 和 parent 关系读回内存;选择当前分支依赖日志末尾恢复出的 leaf;生成当前 context 才是一次沿 parentId 的回溯。

版本字段目前为 v3:v1 是线性序列,v2 加了 id/parentId 变成树,v3 将 hookMessage 改名为 custompackages/coding-agent/docs/session-format.md:19-27)。旧文件加载时自动迁移,migrateV1ToV2 给每个 entry 补 id 和 parentId,migrateV2ToV3 做角色改名(packages/coding-agent/src/core/session-manager.ts:231-292),迁移发生后整个文件重写一遍(session-manager.ts:917-919)。

写路径还增加了缓冲。pendingSessionWrites 队列声明在 packages/agent/src/harness/agent-harness.ts:186。运行中的 mutation 不直接写盘:setModelsetThinkingLevel 这类调用在 harness 忙时只往队列里推一条记录,例如 setModel 的入队逻辑在 agent-harness.ts:951-955。队列的 flushPendingSessionWrites() 实现在 agent-harness.ts:554-578;它会在下一轮 turn 前、turn_endagent_end 和执行兜底路径中调用。turn_end flush 完发一个 save_point 事件(agent-harness.ts:586-597)。在这些 flush 点完成保存后,持久化事件以 turn 为粒度,减少正常运行期间写入半个 turn 的情况。代价是写路径从「追加一行」变成了「缓冲队列加 flush 时机加 save point 事件」。

树结构支持的能力

树结构直接提供了分支导航所需的路径关系:/tree 打开树形选择器在节点间跳转,支持按文本搜索节点,/fork 选中一条用户消息、把这条消息之前的整段路径复制成新会话文件(选中对象必须是用户消息,见 packages/agent/src/harness/types.ts:532-533;header 里 parentSession 指向原文件),/clone 在当前位置复制整个会话(packages/coding-agent/src/core/slash-commands.ts:31-33)。进程级的恢复是 pi -c(继续上次会话)和 pi -r(选择器里挑一个,选择器里也能直接删除旧会话)(packages/coding-agent/src/cli/args.ts:85-88)。跳到一条用户消息节点时,如果输入框为空,这条消息的文本会被放回输入框(packages/coding-agent/src/modes/interactive/interactive-mode.ts:1736-1740),你可以改两个字重新发,旧分支不动。label entry 是给节点打的书签,树大了以后靠它定位。

第二件事是分支摘要,这是我认为整套设计里最有说服力的一件。/tree 跳节点时可以选 summarize:harness 收集从旧 leaf 到共同祖先之间、即将被放弃的那段分支,让模型总结成一段摘要,然后通过 moveTo 在新分支上挂一个 branch_summary entry(agent-harness.ts:876-925session.ts:400-421)。这段摘要以 user 角色的消息进入后续 context,开头写明出处:“The following is a summary of a branch that this conversation came back from”(packages/agent/src/harness/messages.ts:12)。摘要还附带被放弃分支上读过、改过的文件清单(packages/agent/src/harness/compaction/branch-summarization.ts:23-28)。

用消息数组实现这个功能时,需要额外定义「被放弃的分支」的范围。树结构中该范围就是旧 leaf 到共同祖先之间的路径;摘要功能仍需通过 UI、模型调用和 entry 写入实现。

分支摘要的落地:写入、保留与恢复

分支摘要:旧分支保留,摘要作为新分支上的 entry 进入 context

「被放弃的分支」范围由树结构给出。 navigateTree(targetId, { summarize: true }) 拿到旧 leafId(e7)和目标 targetId(e3)后,调用 collectEntriesForBranchSummary:先用 getBranch(oldLeafId) 取旧 leaf 到根的路径、getBranch(targetId) 取目标路径,从目标路径尾端向前找第一个同时出现在旧路径里的节点,作为 commonAncestorIdbranch-summarization.ts:71-87);再从旧 leaf 沿 parentId 回溯,直到 commonAncestorId 为止,沿途 entry 反转成「祖先→旧 leaf」顺序,就是要摘要的那段(:88-99)。上图中范围即 e4 → e5 → e6 → e7。消息数组实现需要额外定义「被放弃分支」的范围;树结构中,该范围可由 parentId 直接确定。

摘要由一次独立的模型调用生成。 prepareBranchEntries 从尾部累加 message(跳过 toolResult),同时累积读过/改过的文件清单(:127-166);generateBranchSummary 把这些消息序列化进 <conversation> 块,拼上固定 prompt(Goal / Constraints / Progress / Key Decisions / Next Steps,:173-200),以 completeSimpleWithRetries 请求一次摘要模型,响应文本再拼上 BRANCH_SUMMARY_PREAMBLE(“The user explored a different conversation branch before returning here”)和文件清单(:264-267)。摘要模型拿到的只是这段分支的内容,不涉及目标分支的后续对话,摘要因此聚焦于「这段探索做了什么」。

entry 怎么写入、旧节点会不会删? 旧分支的 entry 不会删除。navigateTree 计算出 newLeafId(跳到用户消息时取它的 parentId,等于把那条消息放回输入框;其它节点直接用 targetId),随后调用 session.moveTo(newLeafId, summary...)agent-harness.ts:911-921)。moveTo 内部只做两件事(session.ts:400-421):先 setLeafId(entryId) 把运行时 leafId 改成 newLeafId,再 appendTypedEntry 追加一条 branch_summary entry,其 parentId 用的是改完后的新 leafIdfromId 也记成这次跳转的目标。等价地说,分支摘要 entry 是挂在新分支上、而不是贴在旧分支末尾。被放弃的 e4..e7 仍完整留在 JSONL 和内存的 byId / entries 数组里,/tree 随时还能跳回去看全文。

文件里的存储形态。 分支摘要 entry 的结构是 BranchSummaryEntry:除了 SessionTreeEntryBaseid / parentId / timestamp,还持久化 summary(文本,已含前言和文件清单)、fromId(跳转目标)、details{ readFiles, modifiedFiles } 结构化清单)、usage(摘要模型计费)、fromHook(是否 hook 提供的摘要)。它被 appendFile(JSON.stringify(entry) + "\n") 像普通 entry 一样追加到 JSONL 末尾,文件顺序里它排在旧分支 entry 之后、新分支后续对话 entry 之前。此时文件顺序和 parentId 树顺序进一步分离:文件追加日志里 e7 之后是 leaf entry、再是 branch_summary、再是新对话;但 parentId 树里 branch_summary 的父是 e3,leaf entry 的父是 e7。

重启后怎么恢复当前 context。 前一节描述的恢复逻辑在这里直接生效:后端顺序扫描 JSONL,ArraySessionIndex.replace 将每条 entry 写入 byIdentries,同时维护 nextLeafId——读到普通 entry 取 entry.id,读到 leaf entry 取 entry.targetIdarray-session-index.ts:86-105)。扫描完整个文件后,leafId 最终落在新分支上(上图 e10),因为它的 append 顺序在后。取当前 context 时,从 leafId=e10 开始沿 parentId 回溯 e10 → e9(branch_summary) → e3 → e2 → e1buildContextEntriesbranch_summary entry 投影成一条 role: branchSummary 的消息,再由 convertToLlm 包装成 user 角色文本(BRANCH_SUMMARY_PREFIX + summary + BRANCH_SUMMARY_SUFFIXmessages.ts:141-146)注入 context。旧分支 e4..e7 不在这条回溯路径上,因此不进当前 context;但它们仍在 byId 里,点 /tree 就能看见并跳回去。一句话:摘要折叠的是 context 投影,不是日志,和 compaction 的原则一致。

Compaction 机制

长会话需要压缩以控制上下文长度。pi 的 compaction 大部分代码在 packages/agent/src/harness/compaction/compaction.ts(CLI 目前跑的是 packages/coding-agent/src/core/compaction/ 下的同源实现,仓库正处在向 agent 包收拢的过渡期,两边逻辑一致,下文引用以 agent 包为准)。

compaction 的切割与摘要流程

token 估算不引入 tokenizer。 estimateContextTokens 取最后一条有效 assistant 消息的 provider usage 当锚点,这反映了那一刻 provider 眼里的真实上下文大小;锚点之后的 trailing 消息按字符数除以 4 估算,图片按 4800 字符折算(compaction.ts:232-260:268-284)。误差存在,但触发判断只需要数量级正确,换来的是零依赖和即时计算。

触发逻辑分层。 shouldCompact 是纯函数:contextTokens 超过 contextWindow - reserveTokens 就返回 true,默认 reserveTokens 16384、keepRecentTokens 20000(compaction.ts:263-266:174-178)。agent 包只提供这个判定和手动 compact(),自动触发在上层 coding-agent(packages/coding-agent/src/core/agent-session.ts:2038),而且那边还处理了边角情况:最后一条消息是 error 或 usage 全零时,回退到估算值再判断,避免持续报错的会话无法触发压缩(agent-session.ts:2019-2036)。判定和自动触发位于不同层。

切割点有安全约束。 findCutPoint 从尾部向前累加 token,达到 keepRecent 预算后停止(compaction.ts:396-444)。可切割的位置由 findValidCutPoints 预先筛过,明确排除 toolResult(compaction.ts:344-346):从 toolResult 前面切,保留侧就会出现一条没有对应 tool call 的孤儿 tool result,多数 provider 直接拒绝这种请求。这个约束写在候选集合层面,切割算法想违反都做不到。

切在 turn 中间要补一次摘要。 预算耗尽点落在某个 turn 中间时,findTurnStartIndex 找回这个 turn 的起点(compaction.ts:369-383),标记为 split turn。被切掉的 turn 前缀用专用 prompt 单独摘要一次,重点是「给保留的后半段提供理解所需的上下文」(TURN_PREFIX_SUMMARIZATION_PROMPTcompaction.ts:715-728),再和历史摘要拼接(compaction.ts:762-796)。分开摘要可避免将未完成的 turn 前缀与已完成历史混合,降低把中间状态表述为既定事实的风险。

增量更新而非全量重算。 路径上已有上一次 compaction 时,取出它的摘要作为 previousSummary(compaction.ts:656-664),这次只把新增历史交给 UPDATE prompt 在旧摘要上改:保留既有条目、把 In Progress 里完成的挪到 Done、更新 Next Steps(compaction.ts:483-520)。全量重算每次都要重新通读全部历史,token 成本随会话长度线性涨;增量方式每次只处理上次压缩以来的增量。

文件操作清单。 代码扫描被压缩历史中的工具调用,提取读过和改过的文件,并以 <read-files><modified-files> 两段附在摘要文本尾部(compaction.ts:815-816packages/agent/src/harness/compaction/utils.ts:62-73),同时结构化写入 entry 的 detailscompaction.ts:824)。摘要可能省略具体文件路径;该清单保留继续执行任务时所需的文件事实。摘要承载语义概括,清单承载文件操作记录。

摘要请求不进主会话的缓存计价。 发起摘要的 completeSimpleWithRetries 强制 cacheRetention: "none",并给请求分配独立的 sessionId(compaction.ts:126-131)。代码注释说明摘要是一次性请求,写入的 prompt cache 不会复用,因此不参与主会话的缓存亲和和计价。

最后一点呼应树模型:摘要本身落盘为 compaction entry,被压缩的原始历史一条不删,/tree 随时可以跳回去看全文。compaction 折叠的是 context 投影,不是日志。审计和回溯能力因此不受压缩影响。

跨 provider handoff:消息携带 provider 元数据

树解决「存什么」;还需要处理「向哪个 provider 发送」。pi 的 AssistantMessage 上带着 api、provider、model 三个字段(packages/ai/src/types.ts:399-413),thinking 块带 thinkingSignature(Anthropic 的签名、OpenAI 的 reasoning item ID 这类),被安全过滤的 thinking 标记 redacted,加密 payload 原样存在 signature 字段里(packages/ai/src/types.ts:344-353)。策略是:harness 不理解这些签名的含义,但要原样存下来、原样送回去,因为 provider 在多轮续接时需要它们。

消息携带出处之后,中途换模型甚至换 provider 就成了内置能力:model_change entry 记录切换动作,deriveSessionContextState 重放时甚至能从 assistant 消息自身恢复出模型信息(session.ts:51-52),和显式的 model_change 互为补充。剩下的问题是把 A provider 格式的历史翻译成 B provider 能接受的样子,这是 transformMessages()packages/ai/src/api/transform-messages.ts:64)的职责,图片降级、ID 归一化、孤儿 tool call 修补这些规则都在里面,下一篇展开。

Session、Context 与模型消息:三层对象的关系

entry 树、JSONL、compaction 和分支摘要分别处于不同层次。Session 管理完整且可持久化的会话历史;Context 是按当前 leaf 从该历史构建的单次 turn 状态快照;模型消息数组是 Context 中最终交给 provider 的对话输入。 三者按「日志 → 当前路径 → 模型请求」的方向派生,并不分别维护三份独立数据。

Session、Context 与模型消息的类和数据关系

1. Session:会话操作的统一入口

Sessionpackages/agent/src/harness/session/session.ts:152-422)不是文件本身,也不是单纯的消息数组。它持有运行时 leafId 和串行写入的 appendTail,对上提供 appendMessage()moveTo()getBranch()buildContext() 等会话语义,对下仅依赖 SessionStorage 接口。每次追加由 enqueueAppend() 创建 id、把当时 leafId 写为新 entry 的 parentId、交给 storage 落盘,再把运行时 leaf 更新为新 entry(或 leaf entry 的 targetId)(:237-254)。因此 Session 是「向树写入、在树上跳转、从树取路径」的协调者。

SessionRepository 是 Session 的生命周期工厂:createopenlistdeleteforksession/repository.ts:22-32)。真实运行时的 JsonlSessionBackend 实现它,创建或打开 JSONL 文件,并为每个已打开文件维护 ArraySessionIndex;测试则可替换为内存后端。这样 Session 不知道数据来自文件还是内存。

2. Storage 与 Index:保存全量事实,不替 Session 决策

SessionStorage 是单个已打开会话的最小读写契约:读 head、按 ID 读 entry、追加 entry、从 leaf 回溯路径、查询分支、读 label/name/stats(types.ts:558-571)。JsonlSessionBackend.storage() 把这些调用转给对应文件的 ArraySessionIndex;写入时同时追加 JSONL 并更新索引(jsonl-repo.ts:395-415)。

ArraySessionIndex 是文件内容的内存索引:它保存 append 顺序的 entries、按 ID 查找的 byId、扫描恢复的 leafId,以及 label、名字、token/cost 等投影(array-session-index.ts:60-99)。它可从给定 leaf 沿 parentId 查询路径,但不负责控制当前 turn;当前 turn 的组织由 SessionAgentHarness 完成。所以即使 compaction 或分支摘要改变模型看到的内容,原始 entry 依然完整保存在 index 和 JSONL 中。

3. Context:当前路径的临时投影,而非另一份日志

一次 turn 开始时,Session.buildContext() 先从当前 leafIdgetBranch(),得到根到当前 leaf 的 SessionTreeEntry[],再调用 buildSessionContext()session.ts:140-150:201-206)。它完成三件彼此独立的事:

  1. deriveSessionContextState() 顺序重放路径中的 thinking_level_changemodel_changeactive_tools_change 等状态 entry,得到当前的 model、thinkingLevel、activeToolNames(:41-59)。
  2. defaultContextEntryTransform() 根据最近的 compaction entry 折叠早期历史,只留下 compaction 摘要与保留尾部;若有额外 entryTransforms,继续在这里执行(:61-102)。
  3. sessionEntryToContextMessages() 将保留的 entry 投影为 AgentMessage[]:message entry 直接给出消息;compaction 和 branch_summary 变成摘要消息;状态、label、leaf 等不产生模型消息(:105-137)。

最终 SessionContext 的结构为 { messages, model, thinkingLevel, activeToolNames }types.ts:466-471)。它随每次 buildContext() 重新计算,不单独写文件;换 leaf、切分支或新增 entry 后,下次调用自然得到不同的 Context。日志保存所有可能路径,Context 只代表当前选择的一条路径。

4. 给模型的消息数组:Context 的 messages,加上独立的 system prompt 和工具

AgentHarness.createTurnState() 在每轮开始调用 this.session.buildContext(),将其中的 context.messages 放进 turn state(agent-harness.ts:395-428)。随后 createContext() 拷贝这些 messages、绑定当前启用的 tools,再将独立生成的 systemPrompt 放入 AgentContext:431-440),最终交给 models.streamSimple()。这也解释了为什么首个持久化树节点通常是 user message:system prompt 不属于 Session 的 entry 树,也不在 SessionContext.messages 中作为一条普通历史消息;它是 harness 按当前环境在每个 turn 单独准备的请求字段。

模型返回后,harness 再将 user、assistant、tool call/result、状态变化等写成新的 entry,更新 Session 的 leaf;下一轮重复「全量日志 → 当前路径 → Context → 模型请求」的投影过程。这个方向只有从左到右的数据派生,反向不会把模型消息数组当作事实源重建 JSONL。

收尾

pi 的 Session 设计把「会话历史」从线性的消息数组扩展为可追加、可回溯的 entry 树:消息、模型切换、思考等级、工具集和摘要都以同一种记录进入持久化日志。这样做的直接结果是,重启后可以从日志恢复当前分支及其状态;跳转历史节点不会覆盖旧记录;compaction 和分支摘要只改变当前 Context 的投影,不改变原始历史。

这套设计刻意区分了三件事:JSONL 和 entry 树保存完整事实,Session 负责追加与分支操作,SessionContext 则在每次 turn 前从当前 leaf 重新构建模型输入。模型看到的消息数组因此是一个可替换的派生结果,而不是会话的事实来源。换 provider、调整工具集或切换分支时,系统仍能以同一份 entry 历史为依据重放状态。

相应的代价也明确:写入需要处理追加顺序与缓冲时机;树导航和摘要需要额外的 UI 与模型调用;compaction 的可用性受摘要质量约束,文件操作清单只能补充摘要中容易遗漏的路径信息。Pi 没有试图消除这些成本,而是将它们分别编码为 entry、索引、投影和独立的请求流程,使每一步都能从持久化记录中检查和恢复。


1439 字 · 81 段落
xi ming

Written by xi mingFollow onGitHub