Files
analysis_claude_code/learn-pi-agent/s06_turn_snapshot

s06: Turn Snapshot — 一轮开始,先拍一张

开始即冻结:本轮用的东西,后面改了也不算。 Pi 边界:一轮状态边界 —— 一轮一旦开始,它依赖的配置就定死了,外部怎么变都不影响这一轮。

上一节s05s06下一节s07


问题

前面几节里,每一轮的信息都是现场读provider 输入每次都从当前的 state 和 registry 临时拼。

问题在于:如果一轮进行到一半,外部又改了 registry加了个工具、删了个工具这一轮就前后对不上了——provider 这一轮第一次看到的工具列表,和后来看到的不一样。一轮执行到一半被外部改动干扰,结果就说不清。

可以先看一个小事故provider 第一轮看到 1 个工具,工具执行过程中外部又注册了第 2 个工具。下一轮如果重新读 registry同一个 turn 里的工具集合就变了。模型看到的世界前后不一致,调试时很难判断到底是哪一轮出了问题。

s06 要在一轮开始时,把本轮依赖的东西先固定下来


解决方案

一轮开始先拍一份快照 TurnSnapshot,固定 messagestools。之后本轮的 tools 都用快照里的,不管外部怎么改 registry。

AgentState + tools  →  TurnSnapshot  →  本轮 ProviderInput

先记住这条snapshot 不是把整个世界冻住,只是把本轮需要稳定的输入固定下来。

这里有个关键区分(也是和 Pi 对齐的地方):快照固定的是"外部可变的配置"tools不是所有东西。

  • tools外部能改registry 随时变),所以要固定。
  • messagescore 内部的,循环里 toolResult 会不断追加,取实时值。
  • model:是 agent 级的跨轮配置,放 AgentState不进单轮快照——对齐 Pi 的 AgentContext(它也不含 model

工作原理

先定义快照。 两个字段:本轮的消息、本轮的工具说明。

export type TurnSnapshot = {
  messages: ProviderMessage[];
  tools: ToolSpec[];
};

model 不在快照里,在 AgentState。 这是和"把 model 当输入参数"的区别——model 是 agent 的跨轮配置,一轮内不变、跨轮可换,所以它属于状态,不属于单轮快照。

export type AgentState = {
  messages: AgentMessage[];   // s07 会升级为 SessionTree
  model: string;              // s06 起加:跨轮配置
};

在循环开始前拍。 runEventedToolLoop 进循环前,由调用方先 createTurnSnapshot。之后整个循环都用这份快照的 tools。

const snapshot = createTurnSnapshot(state, registry);

本轮输入从快照取。 buildProviderInputFromSnapshotmessages 用实时的循环内会增长tools 用快照的(固定)。

export function buildProviderInputFromSnapshot(
  snapshot: TurnSnapshot,
  state: AgentState,
): ProviderInput {
  return {
    messages: toProviderMessages(state.messages),   // 实时
    tools: snapshot.tools,                          // 固定
  };
}

这一节真正建立的是一轮状态边界一轮一旦开始它依赖的配置tools就冻结了。messages 该增长还增长model 在 state 里跨轮——这正好对齐 Pi 的 AgentContext(固定 systemPrompt/messages/toolsmodel 在 AgentState)。


试一下

运行:

npm run s06

输出类似:

s06: Turn Snapshot

[snapshot 固定性]
snapshot.tools: 1
registry 现在: 2
state.model: demo-small跨轮配置不在 snapshot

[user]
现在几点?

message_start
tool_call: current_time
...
message_end: stop

[provider 看到的 tools]
tools: 1

观察重点:[snapshot 固定性] 里 snapshot 拍下时只有 1 个工具,之后 registry 加到 2 个,但快照没变;[provider 看到的 tools] 也是 1——本轮 provider 自始至终只看到快照里的那一个。model 在 state.model,不进快照。


接入主线

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

组件 s05 s06
AgentState { messages } { messages, model }(加 model 跨轮配置,对齐 Pi
新增类型 TurnSnapshot { messages, tools }
新增函数 createTurnSnapshot / buildProviderInputFromSnapshot
runEventedToolLoop (..., userInput, output) 接收外部拍好的 snapshot(替换 userInput

焊接点:调用方先 createTurnSnapshot(state, registry);循环内 buildProviderInputFromSnapshot(snapshot, state)——messages 实时、tools 固定。model 在 AgentState不进 ProviderInput/snapshot对齐 Pi 的 Context 不含 model


接下来

到现在为止历史还只是一根直线——messages 是个数组,只能一条接一条往后排。

下一节会让历史能分叉:从中间某条岔出去,再走一条不同的路。

进入下一节:s07


Pi 源码溯源AgentContext —— 每轮一份不可变快照

教学版的 TurnSnapshot 固定 messages/tools。Pi 的等价物叫 AgentContext,每轮新建、不可变。

源码在哪

  • packages/agent/src/types.ts:387AgentContext
  • packages/agent/src/agent-loop.ts:103 — 每轮拷贝构造

AgentContext 的真实形状

interface AgentContext {
  systemPrompt: string;       // 本轮系统提示(固定)
  messages: AgentMessage[];   // 本轮对话历史(固定)
  tools?: AgentTool[];        // 本轮工具(固定)
}

教学版的 TurnSnapshot 字段messages/tools是 AgentContext 的子集——Pi 还固定了 systemPrompts08 会引入)。注意 Pi 把 model 放在 AgentState(不在 AgentContext,因为 model 是跨轮的配置,不是单轮快照内容。教学版 s06 正是对齐了这点model 在 AgentStateTurnSnapshot 不含 model。

每轮新建,浅拷贝

agent-loop.ts:103

const currentContext: AgentContext = {
  ...context,
  messages: [...context.messages, ...prompts],   // 浅拷贝新数组
};

每轮创建新的 context 对象messages 用新数组——本轮往里 push toolResult 不会污染原始 context。这正是教学版 snapshot 的"固定"语义。

turn_start / turn_end 事件

Pi 在每轮边界发事件(types.ts:408turn_startturn_end(带 message 和 toolResults。UI 和 extensions09靠它们观察一轮起止——教学版没有"轮"事件。

convertToLlm发之前再过滤

AgentContext.messages 是 core 内部的完整历史。真正发给 provider 前Pi 还有一道 convertToLlm 过滤——把不该发给 LLM 的消息剔掉。snapshot 固定 core 侧convertToLlm 管 provider 侧,两道关一起保证一轮输入既稳定又干净。

一句话

教学版的 TurnSnapshot 立的是"一轮开始把输入固定下来"。Pi 用 AgentContext 坐实它:每轮新建不可变副本 + turn 事件 + 发送前的 convertToLlm 过滤。关键对齐点:model 在 AgentState 不进快照,两边一致。