Files
analysis_claude_code/learn-pi-agent/s07_session_tree/README.md
2026-06-16 00:10:35 +08:00

7.8 KiB
Raw Blame History

s07: Session Tree — 历史不是一条线,是一棵树

走错了能回头,回头还能换条路。 Pi 边界:会话历史边界 —— 历史存成一棵树,一轮输入只取当前这条路径。

上一节s06s07下一节s08


问题

到 s06 为止消息都存在一个数组里只能一条线往后加A → B → C。

但现实里经常想这样:走到 B发现不太对想退回 A 换个方向再试一次。数组做不到——它只会往后追加,没有"回到某个点、从那里分叉"。

s07 要让历史能分叉


解决方案

给每条消息记一个 parentId,指向它的上一条。这样历史不再是一条线,而是一棵树:同一个节点可以长出多条分支。

        user(方案A)
        /        \
  asst(A的回答)  asst(改走方案B)

currentPath 从当前位置一路回溯到根,得到当前这条线的消息序列——provider 拿到的还是线性的消息,对外完全没变。moveTo 切换当前位置,就能走到另一条分支。

注意s07 不是让 provider 理解一棵树。树只存在于 core 内部用来支持回到历史点再分叉provider 仍然只看当前路径上的线性 messages。

[U1 升级] AgentState.messages(数组)升级为 SessionTree。这是受控升级:数组没法表达分叉,所以是替换。但 currentPath() 仍产出线性 AgentMessage[]ProviderInput 的构造方式一字不变——升级藏在 core 内部,不漏到外面。


工作原理

先定义节点。 一个节点就是一条消息,外加它在树里的位置。

export type SessionEntry = {
  id: string;
  parentId: string | null;
  message: AgentMessage;
};

SessionTree 做三件事。 追加、切换位置、读当前路径。

export class SessionTree {
  private entries = new Map<string, SessionEntry>();
  private activeLeafId: string | null = null;
  private counter = 0; // 实例级:每个树独立计数

  append(message: AgentMessage): SessionEntry {
    const entry = { id: `e${++this.counter}`, parentId: this.activeLeafId, message };
    this.entries.set(entry.id, entry);
    this.activeLeafId = entry.id;
    return entry;
  }

  moveTo(entryId: string): void { /* 切换当前位置 */ }

  currentPath(): AgentMessage[] {
    // 从 activeLeaf 一路回溯到根,反转,得到当前这条线
  }
}

append 总是接在当前位置后面;moveTo 把当前位置挪到任意已有节点(分叉的起点);currentPath 回溯出当前这条线。切位置不会删掉旧节点——它们还在树里,只是不在当前路径上。

id 计数器是实例级的this.counter),不是全局变量。这样多个 SessionTree 互不干扰,也不会因为新建一个树就接着旧树的编号往后数。

对外不变。 createTurnSnapshotbuildProviderInputFromSnapshot 现在从 state.session.currentPath() 取消息,但产出的还是线性的 ProviderMessage[]——provider 这边感觉不到 core 内部已经从数组换成了树。

这一节真正建立的是会话历史边界:历史在 core 内部是一棵树,但对一轮输入来说,它永远是"当前这条路径"的线性投影。后面 s08 会往输入里加项目资料,但"历史 = 当前路径"这条规矩,从这里立起来。


试一下

运行:

npm run s07

输出类似:

s07: Session Tree

[路径:方案 A]
user: 方案 A
assistant: A 的回答

[路径:方案 B]
user: 方案 A
assistant: 改走方案 B

[所有节点]
e1 parent=null user: 方案 A
e2 parent=e1 assistant: A 的回答
e3 parent=e1 assistant: 改走方案 B

观察重点:e2e3 的 parent 都是 e1——从同一个节点分叉出两条路;切到方案 B 后,[路径:方案 B] 只含 e1e3,不含 e2


接入主线

s07 在 s06 上累积。相对 s06 的变更:

组件 s06 s07
AgentState { messages: AgentMessage[] } { session: SessionTree }U1 升级)
新增类型 SessionEntry / SessionTree
消息写入 state.messages.push(...) state.session.append(...)
消息读取 state.messages state.session.currentPath()
createTurnSnapshot / buildProviderInputFromSnapshot state.messages state.session.currentPath()
ProviderInput 构造 不变currentPath 产出线性消息)

焊接点:消息读写全改为走 state.session;但 currentPath() 产出线性 AgentMessage[],所以 ProviderInput / TurnSnapshot 的构造逻辑一字未动。U1 升级藏在 core 内部。


接下来

现在一轮输入里有:当前路径上的历史、工具说明、模型名。

下一节会再往输入里加一样东西——项目本身的资料(比如一份说明文档、一个可复用的提示词)。

进入下一节:s08


Pi 源码溯源:持久化的 SessionTree 和 11 种 entry

教学版的 SessionTree 在内存里、只有 message entry。Pi 的 session 是持久化的树,节点有 11 种类型,远不止消息。

源码在哪

  • packages/agent/src/harness/types.ts:334 — SessionTreeEntry 联合类型
  • packages/agent/src/harness/types.ts:409 — 11 种 entry
  • packages/agent/src/harness/session/session.ts:82 — Session 实现
  • packages/agent/src/harness/session/session.ts:246moveTo(分支)

不只是消息11 种 entry

教学版的 SessionEntry 只有 { id, parentId, message }。Pi 的 entry 是个大联合(types.ts:409

type SessionTreeEntry =
  | MessageEntry              // 消息(教学版唯一有的)
  | ThinkingLevelChangeEntry  // 改了推理强度
  | ModelChangeEntry          // 换了模型
  | ActiveToolsChangeEntry    // 启用/禁用了工具
  | CompactionEntry           // 做了上下文压缩s08 方向)
  | BranchSummaryEntry        // 分支摘要
  | CustomEntry / CustomMessageEntry  // 自定义内容
  | LabelEntry                // 给某个节点打标签
  | SessionInfoEntry          // 会话元信息
  | LeafEntry;                // 当前活跃叶子

历史不只记"说了什么",还记"中途换了什么"——换模型、换工具、压缩上下文都是树上的节点。这样回到任何一个历史点,能完整还原当时的配置。

parentId + moveTo = 真分叉

每个 entry 都有 parentIdtypes.ts:337moveTo(entryId)session.ts:246)切换当前位置:

async appendMessage(message) {
  return this.appendTypedEntry({
    type: "message", id: ...,
    parentId: await this.storage.getLeafId(),   // 挂在当前叶子下
    timestamp: ..., message,
  });
}

新节点总挂在当前叶子下;moveTo 把叶子指针挪到任意历史节点,再 append 就长出一条新分支。和教学版的 SessionTree 一模一样的心智,但 Pi 的分支还能带 BranchSummaryEntry 记录"为什么岔出去"。

持久化,不是内存

教学版的 SessionTree 在内存里、进程退出就没了。Pi 的 session 走 storagesession.ts),落盘持久化——关掉重开能恢复,能跨会话。LeafEntry 专门跟踪"当前在哪条分支",持久化后重启能接上。

边界

moveTo 一个不存在的 id 抛 SessionError(教学版也抛错,一致)。分支不会删旧节点——它们留在树里,只是不在当前路径上。

一句话

教学版的 SessionTree 立的是"历史是树、能分叉、一轮输入取当前路径"。Pi 把它坐实成持久化的树 + 11 种 entry消息/模型变更/工具变更/压缩/分支摘要…parentId + moveTo 实现分叉。教学版只留 MessageEntry 和分支骨架。