跳转到内容

最小 Agent Loop 源码导读

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

完成本阶段后,不需要逐行阅读源码,也应当能够:

  • 解释一次 Agent.prompt() 如何变成多次模型调用;
  • 区分 Agent Run、Turn、Message 和 Tool Execution;
  • 手写 Pi Agent Loop 的核心伪代码;
  • 解释为什么 Loop 使用内外两层循环;
  • 说明 Tool Result 为什么必须回传给模型;
  • 说明 steering 和 follow-up 的注入时机;
  • 画出完整事件顺序;
  • 解释错误、取消和 listener settlement。

阶段 1 回答“系统由什么组成”。本阶段只聚焦最核心的动态行为:

用户消息
→ 模型响应
→ Tool Call
→ Tool Result
→ 再次调用模型
→ 最终文本回答

Coding Agent 与普通聊天程序的本质区别,不是 System Prompt 更长,而是模型可以通过 Tool Call 请求外部动作,并在看到 Tool Result 后继续推理。

最小循环可以写成:

messages 加入用户消息
while true:
assistantMessage = 调用模型(messages, tools)
messages 加入 assistantMessage
if assistantMessage 没有 Tool Call:
break
toolResults = 执行所有 Tool Call
messages 加入 toolResults
return 本次新增的 messages

Pi 的真实实现还必须处理:

  • 流式输出;
  • Tool 参数校验;
  • 多个 Tool Call;
  • 并行或串行执行;
  • Tool 执行进度;
  • steering 和 follow-up;
  • abort;
  • 模型错误;
  • Context 转换;
  • 每一步的事件;
  • 事件监听器完成后 Agent core 才 idle,并区分更上层的 agent_settled。

因此,真实结构是:

Agent
└─ runWithLifecycle()
└─ runAgentLoop()
└─ runLoop()
├─ streamAssistantResponse()
├─ executeToolCalls()
├─ inner loop
└─ outer loop

一次 Agent.prompt() 或 Agent.continue() 启动一次 Agent Run。

边界事件:

agent_start
...
agent_end

一个 Run 可以包含多个 Turn。例如模型先调用 read,看到结果后调用 edit,最后回答用户,这仍然是一个 Run。

Session 表示长期保留的整个会话,Turn 只表示其中一次模型决策,两者之间还缺少“一次任务执行”的边界。

可以用用户实际操作来理解:

Session = 你和 Pi 长期保留的整个聊天窗口
Run = 一次 prompt()/continue() 驱动的执行与事件生命周期
Turn = 其中一次 Assistant 响应及其全部工具结果

常见情况下,一个 Run 对应用户感知的一次任务处理;但这不是严格的一对一关系。follow-up 可以在 Agent 原本准备结束时继续注入新消息,使同一个低层 Run 承载后续任务。

例如:

Session:整个项目会话
│
├─ Run 1:修复登录 Bug
│ ├─ Turn 1:模型决定读取代码并执行 read
│ ├─ Turn 2:模型决定修改代码并执行 edit
│ ├─ Turn 3:模型决定运行测试并执行 bash
│ └─ Turn 4:模型给出最终回答
│
└─ Run 2:补充回归测试
├─ Turn 1:模型读取现有测试
└─ Turn 2:模型完成修改并总结

Run 为一次执行提供统一的:

  • 并发保护:同一个 Agent 同时只能修改一份消息状态;
  • 取消范围:abort() 知道应当取消哪次执行;
  • 等待范围:waitForIdle() 知道应当等待哪些模型、工具和 listener;
  • 事件边界:使用 agent_start/agent_end 包围整个任务;
  • 动态输入范围:steering 和 follow-up 知道应当加入哪次执行。

因此,Run 的准确含义是:

从一次 prompt() 或 continue() 开始,到本次 Loop 的模型与工具工作结束、agent_end listeners 完成、activeRun 被清理的一次执行生命周期。

需要注意:Run 结束不保证 steering/follow-up 队列一定被清空。例如 finishTurn 会在读取这两个队列前结束当前 Run,排队消息仍可留给后续处理。

一个 Turn 是:

一次 Assistant Message,以及该消息中所有 Tool Call 的执行结果。

边界事件:

turn_start
→ Assistant Message
→ 零个或多个 Tool Execution
→ 零个或多个 ToolResult Message
turn_end

Turn 不是“一个 user + 一个 assistant”。第二个 Turn 可能没有新的用户输入,而是由上一轮 Tool Result 自动触发。

Pi 不是把“一次工具调用”称为 Turn。Turn 表示模型的一轮完整决策周期:

思考:模型生成 Assistant Message
行动:Assistant Message 请求零个、一个或多个 Tool Call
观察:宿主执行这些工具并生成 Tool Result

工具执行属于本 Turn,是因为它是本轮 Assistant Message 作出的决定。必须取得该决定产生的全部结果,这一轮才闭环。

例如一次模型响应同时请求两个工具:

Turn 1
├─ Assistant Message
│ ├─ toolCall: read(a.ts)
│ └─ toolCall: read(b.ts)
├─ ToolResult: a.ts
└─ ToolResult: b.ts

以上是一个 Turn,不是两个。下一次把两个 Tool Result 发给模型时,才开始 Turn 2:

Turn 2
└─ Assistant Message: “根据两个文件,问题在……”

如果 Assistant Message 没有 Tool Call,也仍然构成一个 Turn:

Turn
└─ Assistant Message: “任务已经完成”

所以最简记忆是:

Run = 一次 prompt()/continue() 的执行与事件边界
Turn = 一次“思考、行动、观察”
Tool Call = 一个 Turn 中的一次行动

Loop 主要处理三种模型可理解的消息:

  • system:Prompt sections 与工具声明的历史更新;
  • user:用户输入;
  • assistant:模型文本、thinking 和 Tool Call;
  • toolResult:工具执行结果。

AgentMessage 允许宿主扩展自定义消息,但在调用模型前必须通过 convertToLlm() 转换或过滤。

Assistant Message 中的一个 toolCall content block 对应一次 Tool Execution。

它经历:

tool_execution_start
→ 参数预处理与校验
→ beforeToolCall
→ tool.execute()
→ 若干 tool_execution_update
→ afterToolCall
tool_execution_end

然后执行结果会被包装成独立的 toolResult Message。

模型 Provider 返回的不是直接完成的 Assistant Message,而是一串流式事件:

start
text_start / text_delta / text_end
thinking_start / thinking_delta / thinking_end
toolcall_start / toolcall_delta / toolcall_end
done / error

streamAssistantResponse() 将这些 Provider 事件归并为一个不断更新、最终完成的 Assistant Message。

正常流以 start 开始,并由最终的 done/error 收束;text、thinking、toolcall 事件按本次响应实际内容出现,并不是每次响应都会完整经过全部中间事件。调用模型前 signal 已 aborted 等边界下,Provider 也可能直接发出 error 而没有 start,streamAssistantResponse() 会为最终消息补齐 Agent 层的 message_start/message_end。

关键定义集中在 packages/agent/src/types.ts。

类型 当前源码位置 作用
AgentLoopConfig types.ts 一次 Loop 使用的模型、Context 转换、队列和 Tool hooks
AgentMessage types.ts 通用模型消息与宿主自定义消息的联合类型
AgentState types.ts Agent 对外暴露的长期内存状态
AgentToolResult types.ts 工具执行返回的内容、details、usage 和 terminate
AgentTool types.ts Tool schema、执行函数和执行模式
AgentContext types.ts 进入低层 Loop 的 messages 和可执行 tools
AgentEvent types.ts Run、Turn、Message、Tool 四组生命周期事件

AgentState 是 Agent 长期持有的可变状态:

AgentState
├─ systemPrompt(只读 getter,由 messages 重放得到)
├─ model
├─ thinkingLevel
├─ tools
├─ messages
├─ isStreaming
├─ streamingMessage
├─ pendingToolCalls
└─ errorMessage

AgentContext 是启动一次 Loop 时创建的快照:

AgentContext
├─ messages 的浅拷贝(含 SystemMessage)
└─ tools 的浅拷贝

源码位置:

  • Agent.createContextSnapshot():packages/agent/src/agent.ts

这样做的意义是:

  • Loop 操作自己的 Context 数组;
  • Agent 通过事件把最终消息归并回 AgentState;
  • 宿主可以在 Turn 边界通过 prepareNextTurn 更新下一轮 Context、模型或 thinking level;
  • 外部直接替换 state.messages 或 state.tools 时,不会在当前操作中无意共享顶层数组。

Agent.reset() 清理对话与队列时,会用 getCurrentSystemMessage() 折出当前 Prompt/工具基线并保留为一条 system 消息。因此 reset 后不一定是空数组;Agent.continue() 在无对话、只有 system 的情况下也会拒绝启动。底层 runAgentLoopContinue 的前置检查只检查空数组/末尾 assistant,不要把 Agent 包装层的额外检查误写成所有入口共有。

runLoop() 同时维护两个消息数组:

变量 内容 用途
currentContext.messages 历史消息 + 本次新增消息 作为下一次模型调用的上下文
newMessages 仅本次 Run 新产生的消息 用于 agent_end.messages 和函数返回值

例如历史已有 20 条消息,且工具和 Prompt 无变化,本次新增:

user
assistant(toolCall)
toolResult
assistant(final)

那么:

  • currentContext.messages 最终有 24 条;
  • newMessages 只有这次新增的 4 条。

这避免每次 agent_end 都重复返回整个历史。若本次还声明了 Prompt 或工具变化,新增的 SystemMessage 也要计入数量。

四、三层入口:应该从哪里开始读

Section titled “四、三层入口:应该从哪里开始读”

Pi 同时暴露了三种粒度的入口。

源码位置:packages/agent/src/agent-loop.ts

输入:

  • 新的 prompt messages;
  • AgentContext;
  • AgentLoopConfig;
  • AbortSignal;
  • StreamFn。

输出:

EventStream<AgentEvent, AgentMessage[]>

适合希望通过异步迭代直接消费事件的低层调用者:

const stream = agentLoop(...)
for await (const event of stream):
消费事件
const messages = await stream.result()

agentLoop() 自己不实现循环。它创建 EventStream,异步启动 runAgentLoop(),再把 emit 适配成 stream.push(event)。

源码位置:packages/agent/src/agent-loop.ts

它接受一个 emit(event) 回调,直接返回 Promise<AgentMessage[]>。

它负责:

  1. declareToolChanges(context, prompts) 声明工具差量,再复制到 newMessages;
  2. 把历史 Context 和 prompt 合并为 currentContext;
  3. 发出 agent_start;
  4. 发出第一个 turn_start;
  5. 为 prompt 发出 message_start/message_end;
  6. 进入共享的 runLoop()。

关键初始化可以抽象为:

initialMessages = declareToolChanges(context, prompts)
newMessages = [...initialMessages]
currentContext.messages = [...history, ...initialMessages]
emit(agent_start)
emit(turn_start)
emit(prompt message_start/message_end)
runLoop(currentContext, newMessages)

源码位置:packages/agent/src/agent.ts

Agent 是低层 Loop 的有状态包装器。业务代码通常调用:

Agent.prompt()
Agent.continue()
Agent.steer()
Agent.followUp()
Agent.abort()
Agent.waitForIdle()

它比裸 agentLoop() 多提供:

  • 长期保存 AgentState;
  • 防止同时启动两个 Run;
  • steering/follow-up 队列;
  • AbortController;
  • 事件订阅;
  • 根据事件更新 streaming 和 pending tool 状态;
  • 等待异步 listener 完成;
  • 把意外异常规范化为一组失败事件。

所以调用关系不是三个不同实现,而是三层封装:

Agent.prompt()
→ runAgentLoop()
→ runLoop()
agentLoop()
→ runAgentLoop()
→ runLoop()

关键逻辑:Agent.prompt(),agent.ts

如果 activeRun 已存在,prompt() 会报错:

Agent is already processing a prompt.
Use steer() or followUp() ...

原因是同一个 AgentState.messages 不能被两个 Loop 并发修改。用户在 Agent 工作过程中继续输入,必须明确选择:

  • steer:尽快影响当前工作;
  • followUp:等当前工作自然完成后再处理。

activeRun 是每个 Agent 实例自己的私有字段,不是进程级或全局锁:

AgentSession A
└─ Agent A
└─ activeRun A
AgentSession B
└─ Agent B
└─ activeRun B

两个 Session 通常各自持有一个 Agent,因此可以同时运行,形成两个独立的 Run。

限制只发生在同一个 Agent:

Agent A.prompt("任务 1") → 创建 activeRun
Agent A.prompt("任务 2") → activeRun 尚未结束,报错
Agent B.prompt("任务 3") → 不受 Agent A 影响

同一个 Agent 的结构是:

Agent
├─ Run 1:已经完成
├─ Run 2:当前 activeRun
└─ Run 3:只能等 Run 2 结束后启动

activeRun 从 runWithLifecycle() 创建 AbortController 时开始存在,直到:

agent_end
→ 所有 awaited listeners 完成
→ finishRun()
→ activeRun 被清空

因此,即使已经观察到 agent_end,listener 尚未完成时,同一个 Agent 仍不能启动新的 prompt()。

normalizePromptInput() 将字符串变成:

{
role: "user",
content: [
{ type: "text", text: input },
...images
],
timestamp
}

如果调用者已经传入 AgentMessage 或数组,则直接使用。

调用链:

prompt()
→ runPromptMessages()
→ runWithLifecycle()
→ runAgentLoop()

关键逻辑:Agent.runWithLifecycle(),agent.ts

它在 Loop 外围完成:

创建 AbortController
创建 activeRun.promise
isStreaming = true
清空 streamingMessage 和 errorMessage
try:
执行 runAgentLoop()
catch:
生成规范化失败事件
finally:
isStreaming = false
清理 pendingToolCalls
resolve activeRun.promise

这里的 isStreaming 实际表示“Agent Run 仍活跃”,不仅表示模型正在输出文本。工具执行和 awaited listeners 尚未完成时,它也保持为 true。

关键逻辑:

  • createContextSnapshot():agent.ts
  • createLoopConfig():agent.ts

Context 是运行数据:

messages(含 system)+ tools(可执行实现)

Config 是运行行为:

model
thinking level
stream options
tool execution mode
convertToLlm / transformContext
beforeToolCall / afterToolCall
finishTurn / prepareNextTurn / prepareRequest
getSteeringMessages
getFollowUpMessages

这种拆分意味着:

  • Context 回答“模型本轮能看到什么”;
  • Config 回答“Loop 应该如何运行”。

关键逻辑:packages/agent/src/agent-loop.ts

先看源码缩写;这里省略配置更新和逐条事件结构:

入口:declareToolChanges(context, prompts)
→ agent_start → turn_start → 初始消息 message_start/end
while true:
hasMoreToolCalls = true
while hasMoreToolCalls or pendingMessages 非空:
若已有上一 Turn:prepareNextTurn → turn_start
declareToolChanges(preparedMessages + pendingMessages)
→ 发出消息事件并加入 Context
prepareRequest → transformContext → convertToLlm → normalizeContext
assistant = streamAssistantResponse()
若 error / aborted:finishTurn(忽略决定)→ turn_end → agent_end → return
执行 Tool Calls(length 时只生成错误结果)
追加 ToolResults,计算批次是否 terminate
decision = finishTurn()
emit(turn_end)
若 decision.action == end:agent_end → return
读取 steering;有工具续轮或 steering 时继续内层
读取 follow-up;有消息则继续外层
若 decision.action == continue 且没有自然续轮:补下一 Turn
否则 break
emit(agent_end)

进入内层循环时,还没有 Assistant Message。将它初始化为 true,是为了保证至少执行一次模型调用。

完成一次模型调用后:

  • 没有 Tool Call:设为 false,内层循环准备结束;
  • 有 Tool Call:通常保持为 true,Tool Result 会触发下一次模型调用;
  • 所有 Tool Result 都要求 terminate:设为 false,不再自动调用模型。

内层循环负责“当前任务仍在推进”:

  • Tool Call 需要模型继续;
  • steering 消息需要尽快注入。

外层循环负责“任务原本已经结束,但用户安排了后续任务”:

  • 当前 Assistant 已经给出最终回答;
  • 没有 Tool Call;
  • 没有 steering;
  • 此时才检查 follow-up。

如果只有一层循环,steering 和 follow-up 就很难表达不同的调度语义。

steering 不是在每个 Tool Call 结束后检查,而是在整个 Turn 完成后检查。

唯一的前置检查发生在 runLoop() 刚启动时:如果 Run 开始前已经有 steering 排队,它会作为第一个 Turn 的 pending message 注入。进入正常循环后,steering 都在完整 Turn 边界检查。

假设一个 Assistant Message 同时请求三个工具:

Turn 1
├─ tool A
├─ tool B
├─ tool C
└─ turn_end
└─ 检查 steering

即使用户在 tool A 执行时提交 steering,也要等待 A、B、C 全部完成。最终消息顺序保持为:

assistant(tool A, tool B, tool C)
toolResult A
toolResult B
toolResult C
user steering
下一次 Assistant Message

不能变成:

assistant(tool A, tool B, tool C)
toolResult A
user steering
toolResult B
toolResult C

后一种排列把新的用户输入插入了一个尚未完成的 Tool 批次,会破坏 Assistant Tool Call 与 Tool Result 的完整对应关系。

后面的对话与事件案例为突出 User/Assistant/ToolResult,省略无变化的系统基线。若 Prompt 或工具集合发生变化,还会先发出 SystemMessage 的 message_start/message_end 并计入新增消息。

假设模型不调用工具,直接回答“完成”。

初始 AgentState.messages
[]
prompt("修复 bug")
["user"]
模型完成
["user", "assistant"]
Agent.prompt("修复 bug")
→ runWithLifecycle()
→ runAgentLoop([user], context)
→ emit agent_start
→ emit turn_start
→ emit user message_start
→ emit user message_end
→ streamAssistantResponse()
→ transformContext()
→ convertToLlm()
→ streamFunction(model, llmContext)
→ 消费 Provider stream
→ emit assistant message_start/update/end
→ 没有 Tool Call
→ emit turn_end
→ steering 为空
→ follow-up 为空
→ emit agent_end
→ listener 全部完成
→ finishRun()
agent_start
turn_start
message_start(user)
message_end(user)
message_start(assistant)
message_update(assistant) × N
message_end(assistant)
turn_end
agent_end

假设用户要求计算 123 * 456。

Faux Provider 预设两次响应:

第一次模型响应:
text("Let me calculate that.")
toolCall("calculate", { expression: "123 * 456" })
第二次模型响应:
text("The result is 56088.")

这说明 Tool Calling 至少需要两次 LLM 请求:

LLM Request 1
输入:[user]
输出:assistant(toolCall)
本地执行 calculate
输出:toolResult("123 * 456 = 56088")
LLM Request 2
输入:[user, assistant(toolCall), toolResult]
输出:assistant(final answer)

为什么 Tool Result 必须重新发给模型

Section titled “为什么 Tool Result 必须重新发给模型”

Tool 是宿主程序执行的,模型不会自动知道执行结果。

Assistant Message 只是表达:

“请调用 calculate,参数是 ...”

宿主执行后必须追加:

{
role: "toolResult",
toolCallId: "calc-1",
toolName: "calculate",
content: "56088"
}

下一次模型请求同时包含 Tool Call 和匹配的 Tool Result,模型才能:

  • 知道哪个调用已完成;
  • 读取真实结果;
  • 判断是否继续调用工具;
  • 生成面向用户的最终答案。
currentContext.messages
1. user("Calculate 123 * 456")
2. assistant(
text,
toolCall(id="calc-1", name="calculate", args=...)
)
3. toolResult(
toolCallId="calc-1",
content="56088"
)
4. assistant("The result is 56088")
agent_start
turn_start
message_start(user)
message_end(user)
message_start(assistant with toolCall)
message_update(...) × N
message_end(assistant)
tool_execution_start
tool_execution_update × N
tool_execution_end
message_start(toolResult)
message_end(toolResult)
turn_end
turn_start
message_start(assistant final)
message_update(...) × N
message_end(assistant final)
turn_end
agent_end

注意第二个 Turn 没有新的 user message。它由 Tool Result 自动触发。

九、模型流如何变成 Assistant Message

Section titled “九、模型流如何变成 Assistant Message”

关键逻辑:streamAssistantResponse(),agent-loop.ts

AgentMessage[]
→ transformContext()
→ AgentMessage[]
→ convertToLlm()
→ Message[]
→ normalizeContext({ messages }) → TranscriptContext
→ streamFunction()

阶段二只需要知道:

  • transformContext() 在 AgentMessage 层调整上下文;
  • convertToLlm() 过滤或转换自定义消息;
  • Provider 差异由更下层的 pi-ai 处理。

完整 Context Engineering 会在阶段 5 展开。

streamAssistantResponse() 维护一个 partialMessage。

收到 start:

partialMessage = event.partial
把 partialMessage 放入 currentContext.messages
emit message_start

收到 text/thinking/toolcall 的增量事件:

partialMessage = event.partial
替换 currentContext.messages 的最后一条
emit message_update

收到 done 或 error:

finalMessage = await response.result()
用 finalMessage 替换最后一条 partial
emit message_end
return finalMessage

为什么是“替换最后一条”,而不是每个 delta 都追加一条消息?

因为 delta 只是同一个 Assistant Message 的不同快照。Context 中应该始终只有一条正在增长的 Assistant Message,否则模型历史会被拆成大量重复消息。

阶段 3 会深入 Tool Engineering。本阶段只掌握 Loop 如何使用工具结果。

关键入口:executeToolCalls(),agent-loop.ts

AssistantMessage.content
→ 提取 toolCall blocks
→ 决定 sequential / parallel
→ prepareToolCall()
├─ 查找 Tool
├─ prepareArguments()
├─ validateToolArguments()
└─ beforeToolCall()
→ 若 preflight 已产生错误或 block:直接形成结果
→ 否则 executePreparedToolCall()
├─ tool.execute()
└─ tool_execution_update
→ finalizeExecutedToolCall()
└─ afterToolCall()
→ tool_execution_end
→ createToolResultMessage()
→ message_start/message_end(toolResult)

tool_execution_start 在 preflight 前发出。只有实际进入 tool.execute() 的调用才会经过 afterToolCall;未知 Tool、参数非法或被 beforeToolCall 阻止的调用会直接得到错误结果。

多个 Tool Call 的调度边界是:

  • Agent 默认使用 parallel;全局设为 sequential,或批次中任一 Tool 声明 executionMode: "sequential",整批都会串行;
  • parallel 模式仍按 Assistant Message 中的源顺序逐个完成 preflight,允许执行的 Tool 随后并发运行;
  • tool_execution_end 按真实完成顺序发出,便于 UI 及时更新;
  • ToolResult Message 和 turn_end.toolResults 按原 Tool Call 顺序写入,保证模型上下文稳定。

工具失败为什么不会直接让 Loop 崩溃

Section titled “工具失败为什么不会直接让 Loop 崩溃”

以下情况都会转换成错误 Tool Result:

  • Tool 不存在;
  • 参数校验失败;
  • beforeToolCall 阻止执行;
  • Tool 抛出异常,或返回 isError: true;
  • afterToolCall 抛出异常;
  • 操作被取消。

模型会在下一轮看到:

role = toolResult
isError = true
content = 错误原因

模型因此可以修正参数、换一种工具或向用户解释失败。

这是 Agent 系统的重要原则:

可恢复的工具失败应进入模型上下文,而不是直接终止整个 Agent Run。

如果模型输出因 token 上限被截断,Tool Call 参数可能只生成了一部分。即使残缺 JSON 恰好能够解析和通过 schema,也不能安全执行。

因此 failToolCallsFromTruncatedMessage() 会:

  • 不执行该 Assistant Message 中的任何 Tool Call;
  • 为每个调用产生错误 Tool Result;
  • 让 Loop 继续;
  • 让模型重新发出完整调用。

工具结果可以返回:

terminate: true

它表示“不需要为了这个工具结果自动再调用一次模型”。

只有一个批次中的所有结果都设置 terminate: true,shouldTerminateToolBatch() 才返回 true:

finalizedCalls.length > 0
AND
every result.terminate === true

混合批次中,只要有一个工具仍需要模型处理,就满足工具自动续轮条件;随后 finishTurn 仍可返回 end。

即使整个批次都 terminate: true,它也只是把“Tool Result 自动续轮”关闭。Loop 随后仍会检查 steering 和 follow-up;队列消息或 finishTurn 的显式 continue 都可能让当前 Run 进入下一 Turn;finishTurn 的 end 则优先结束。

含义:

用户希望新消息尽快影响当前任务,但不破坏正在执行的 Assistant Turn。

注入时机:

当前 Assistant Message 完成
→ 当前批次的所有 Tool Call 完成
→ finishTurn
→ emit turn_end
→ 若决定 end,直接结束 Run
→ drain steering queue
→ 若确认还要继续,prepareNextTurn
→ 下一 Turn 开始时加入 Context

所以 steering 不会:

  • 打断正在生成的半条 Assistant Message;
  • 跳过已经请求的 Tool Call;
  • 插入到一批 Tool Result 的中间。

这保证消息历史仍符合 Provider 协议。

含义:

当前任务自然完成后,再处理这条消息。

检查时机:

没有更多 Tool Call
AND steering queue 为空
→ Agent 原本准备结束
→ drain follow-up queue
→ 若有消息,重新进入内层循环
维度 steering follow-up
目标 调整当前工作方向 排队执行后续任务
检查时机 每个 Turn 结束后 Agent 原本准备停止时
是否等待当前 Tool 批次 是 是
是否等待整个当前任务自然结束 否 是
使用哪层循环 内层循环 外层循环

队列有两种 drain 策略:

  • all:一次取出所有消息;
  • one-at-a-time:一次只取最早的一条。

默认是 one-at-a-time。这样多条用户输入不会被无条件合并为同一个模型 Turn。

十二、三个边界:finishTurn、prepareNextTurn、prepareRequest

Section titled “十二、三个边界:finishTurn、prepareNextTurn、prepareRequest”

当前版本已删除 finishTurn。下面是 runLoop() 的源码缩写,省略工具调度和模型配置赋值:

// Assistant 与 ToolResult 都已发出 message_end,宿主可据此持久化。
const decision = await config.finishTurn?.(lastCompletedTurn, signal);
await emit({ type: "turn_end", message, toolResults });
if (decision?.action === "end") {
await emit({ type: "agent_end", messages: newMessages });
return;
}
// 后续优先采用工具链、steering、follow-up 自然选出的下一请求。
// action=continue 且没有自然请求时,再补一个仅使用当前上下文的 Turn。

finishTurn 在 turn_end 之前执行。返回 end 会在发出 turn_end 后结束 Run,不再取队列;返回 continue 保证至少还有一次请求,但不会在已有工具续轮或队列请求之外多加一轮。返回 undefined 沿用普通调度。Provider 返回 error / aborted 时仍调用它供观察,但忽略继续决定,强制结束当前 Run。

Loop 确认继续以后,才执行 prepareNextTurn;首轮不调用它。它可以返回 context、model、thinkingLevel,以及需要发出消息事件的 messages。

const update = await config.prepareNextTurn?.(lastCompletedTurn);
currentContext = update?.context ?? currentContext;
const preparedMessages = update?.messages ?? [];
// 若前一次没有取到 steering,再补取准备期间到达的消息。
await emit({ type: "turn_start" });
for (const message of declareToolChanges(currentContext, [...preparedMessages, ...pendingMessages])) {
await emit({ type: "message_start", message });
await emit({ type: "message_end", message });
currentContext.messages.push(message);
newMessages.push(message);
}

AgentSession 在这里尝试 threshold compaction、刷新工具实现和 Prompt sections,并把需要声明的 SystemMessage 交还 Loop。模型与 thinking 的刷新发生在请求边界,不会切换正在输出的流。

prepareRequest 则在每次 Provider 请求前执行,包括首轮;此时新增消息已经发出事件并追加。它能更新 Context、模型和 thinking,更新也用于后续请求;它本身不读取 steering/follow-up。

const update = await config.prepareRequest?.({
context: currentContext, model: config.model,
thinkingLevel: config.reasoning ?? "off",
}, signal);
currentContext = update?.context ?? currentContext;
// 应用 model / thinkingLevel 更新后:
const assistant = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);

AgentSession 利用它从 SessionManager.buildSessionProjection() 取得最新的规范上下文,确保边界编辑和刚持久化的消息参与本次请求。三个 hook 的顺序是:上一轮 finishTurn → turn_end,确认继续后 prepareNextTurn → turn_start → 消息事件 → prepareRequest → Provider。

条件 发生位置 结果
Assistant 没有 Tool Call,且队列为空、没有显式 continue 内外循环自然结束 agent_end
Assistant stopReason=error 模型响应后 turn_end → agent_end
Assistant stopReason=aborted 模型响应后 turn_end → agent_end
所有 Tool Result terminate=true,且队列为空、没有显式 continue Tool 批次后 不自动再次调用模型
finishTurn 返回 end Turn 完成后 立即 agent_end
有 steering Turn 完成后 开始下一 Turn
有 follow-up Agent 原本停止时 外层循环重新进入

当前没有内建的“重复调用熔断”

Section titled “当前没有内建的“重复调用熔断””

截至本文锁定的 revision,packages/agent 的 Loop 配置中没有默认 maxTurns、maxSteps,也没有根据 Tool 名称与参数生成指纹、自动识别重复 Tool Call 的逻辑。

因此,如果模型持续生成同一个 Tool Call,低层 Loop 不会仅因“重复”而停止。它仍要依靠这些已有边界退出:

  • 模型不再产生 Tool Call;
  • 整个 Tool 批次都返回 terminate: true;
  • 宿主通过 finishTurn 实现自己的预算或策略;
  • 调用者执行 abort();
  • Provider 返回 error/aborted。

这是当前实现边界,不等于 Agent 产品不应该配置预算。产品层可以利用 finishTurn 统计 Turn 数、累计成本或识别重复模式,再在完整 Turn 边界优雅停止。

StreamFn 的约定是:

  • 请求、模型或运行时错误不应直接 reject;
  • 应通过流式 error 事件和最终 stopReason="error" 表达。

streamAssistantResponse() 因此仍能生成完整的 message_end。

runLoop() 看到 error/aborted 后发出:

turn_end
agent_end

如果 StreamFn、Context hook、listener 或其他代码违反“不抛出”的约定,Agent.runWithLifecycle() 会捕获第一次异常。

handleRunFailure() 人工构造一条 Assistant Message:

role: assistant
content: 空文本
stopReason: error 或 aborted
errorMessage: 异常信息

然后尝试补齐标准事件:

message_start
message_end
turn_end
agent_end

正常情况下,这让 UI 和持久化层仍能收到统一的失败生命周期。这里不是绝对保证:如果 listener 在处理这些失败事件时再次抛出,补偿事件序列本身也可能中断;finally 仍会执行 finishRun(),确保运行状态被清理。

Agent.abort() 调用当前 AbortController.abort()。

同一个 signal 会传给:

  • streamFunction;
  • transformContext;
  • beforeToolCall;
  • tool.execute;
  • afterToolCall;
  • awaited event listeners。

是否能立即终止取决于每个异步实现是否尊重 signal。

关键逻辑:Agent.processEvents(),agent.ts

listener 是外部模块订阅 Agent 生命周期的回调:

const unsubscribe = agent.subscribe(async (event, signal) => {
// 观察或处理 AgentEvent
});

它可以收到:

Agent Run 事件
Turn 事件
Message 流式事件
Tool Execution 事件

典型消费者包括:

  • AgentSession:持久化消息、转发 Extension 事件、维护 retry/compaction 状态;
  • TUI、Print、JSON、RPC:通常通过 AgentSession 的二次转发展示或输出运行过程;
  • 日志和监控;
  • 测试:验证事件和状态顺序。

最重要的实际订阅发生在 AgentSession 构造过程中:

this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);

subscribe() 返回取消订阅函数。Session 被关闭或替换时必须调用它,避免旧 Session 继续收到事件。

listener 还会收到当前 Run 的 AbortSignal,因此自身执行的异步工作也可以响应 agent.abort()。

它先更新内部状态,再通知 listener:

事件 状态变化
message_start streamingMessage = message
message_update 更新 streamingMessage
message_end 清空 streamingMessage,并将消息追加到 transcript
tool_execution_start 将 id 加入 pendingToolCalls
tool_execution_end 从 pendingToolCalls 删除 id
turn_end 若有错误,更新 errorMessage
agent_end 清空 streamingMessage

listener 收到事件时,AgentState 已经反映该事件,因此 UI 可以直接读取一致状态。例如收到 tool_execution_start 时,pendingToolCalls 已经包含对应 id。

processEvents() 按订阅顺序执行:

for listener:
await listener(event, signal)

这不是单纯通知,而是事件屏障。

例如 AgentSession 的 listener 会在 message_end 时把消息写入 Session。如果 Agent.prompt() 在持久化前就 resolve:

  • CLI 可能退出而消息尚未落盘;
  • /new 可能替换 Session;
  • 测试观察到 Run 已完成,但副作用仍在后台执行;
  • listener 错误无法进入统一失败路径。

因此对 packages/agent 这一层,Agent core idle 的定义是:

agent_end 已发出
AND agent_end 的所有 awaited listeners 已完成
AND finishRun() 已清理 activeRun

agent_end 是最后一个 Loop 事件,但不是 Agent core 已完成 settlement 的瞬间。

再向上一层,coding-agent 的 AgentSession 还有更晚的产品级边界。一次低层 Agent Run 完成后,AgentSession._handlePostAgentRun() 仍可能处理自动 retry、compaction,或 agent_end Extension handler 新排入的消息,并通过 agent.continue() 启动新的低层 Run。全部 post-run 工作结束后,它才发出 agent_settled:

agent_end
→ Agent listeners 完成
→ finishRun,Agent core idle
→ AgentSession 检查 retry / compaction / queued continuation
→ 必要时 agent.continue(),产生新的 agent_start ... agent_end
→ 所有待办结束
→ agent_settled

所以两层术语不能混用:

  • Agent core idle:当前低层 Run 的 listeners 已完成,activeRun 已清理;
  • AgentSession settled:产品编排层的重试、压缩、续跑等后续工作也已处理完毕。

十六、EventStream 如何连接生产者和消费者

Section titled “十六、EventStream 如何连接生产者和消费者”

这一节之所以容易突兀,是因为前面已经出现了 listener。先看它们解决的不同问题:

机制 谁产生事件 谁消费事件 生产者是否等待消费者
Agent.subscribe(listener) Agent.processEvents() AgentSession、UI、持久化层 是,逐个 await
EventStream Provider 或低层 Agent Loop 使用 for await 的调用者 否,事件可以先进入队列

listener 是 Agent 有状态封装层的事件屏障;EventStream 是低层异步 API 的传输容器。

模型和 Agent 都不是一次性返回结果:

普通函数:
调用函数 ───────────────→ 最终结果
流式函数:
调用函数 → start → delta → delta → toolCall → ... → 最终结果

生产者不断产生事件,消费者希望事件一出现就处理,而不是等全部完成。两者运行速度也可能不同:

生产者:快速产生 event A、B、C
消费者:还在处理 event A

EventStream 位于两者中间:

生产者 EventStream 消费者
Provider / Agent Loop → queue + waiting → for await

它同时解决:

  1. 尚未被消费的事件放在哪里;
  2. 消费者等待下一事件时由谁唤醒;
  3. 如何判断流已经结束;
  4. 如何在事件之外取得最终聚合结果。
Provider
→ AssistantMessageEventStream
├─ start
├─ text_delta
├─ thinking_delta
├─ toolcall_delta
└─ done / error
→ streamAssistantResponse()
→ 最终 AssistantMessage

这里:

  • 生产者是 Provider;
  • 消费者是 streamAssistantResponse();
  • for await 用来实时处理 delta;
  • response.result() 用来取得最终 Assistant Message。

对应源码逻辑:

const response = await streamFunction(...);
for await (const event of response) {
// 将 Provider delta 转成 message_start/message_update/message_end
}
const finalMessage = await response.result();
runAgentLoop()
→ EventStream<AgentEvent, AgentMessage[]>
├─ agent_start
├─ turn_start
├─ message_update
├─ tool_execution_*
└─ agent_end
→ 低层 API 调用者

这里:

  • 生产者是 runAgentLoop();
  • 消费者是直接调用 agentLoop() 的外部程序;
  • for await 用来观察完整运行过程;
  • stream.result() 返回本次 Run 新产生的消息。

使用方式:

const stream = agentLoop(...);
for await (const event of stream) {
render(event);
}
const newMessages = await stream.result();

agentLoop() 如何把 callback 变成 EventStream

Section titled “agentLoop() 如何把 callback 变成 EventStream”

低层核心 runAgentLoop() 本身只认识一个回调:

emit(event)

包装函数 agentLoop() 创建 EventStream,然后完成适配:

runAgentLoop(..., event => stream.push(event))

可以将它理解为:

runAgentLoop 产生事件
→ emit(event)
→ stream.push(event)
→ queue
→ for await 消费

所以 agentLoop() 没有重新实现 Loop,只是把 callback 风格转换成 async iterable 风格。

关键逻辑:packages/ai/src/utils/event-stream.ts

EventStream<T, R> 同时提供:

  • push(event):生产者写入事件;
  • for await:消费者按顺序读取;
  • result():等待最终聚合结果。

内部维护:

queue:已经产生、尚未消费的事件
waiting:正在等待下一事件的消费者
done:是否完成
finalResultPromise:最终结果

存在两种时序。

情况一:事件先到,消费者还没读取:

push(event A)
→ 没有等待中的消费者
→ event A 放入 queue
消费者下一次 for await
→ 从 queue 取出 event A

情况二:消费者先等待,事件还没产生:

消费者请求下一事件
→ queue 为空
→ 将消费者的 resolve 放入 waiting
push(event A)
→ 取出 waiter
→ 立即把 event A 交给该消费者

因此:

queue = 事件等消费者
waiting = 消费者等事件

for await 得到过程:

agent_start
message_update
tool_execution_start
...
agent_end

result() 得到最终聚合值:

Agent EventStream → 本次 Run 的 AgentMessage[]
Provider EventStream → 最终 AssistantMessage

对于 Agent Loop,构造 EventStream 时声明:

完成事件:event.type === "agent_end"
最终结果:agent_end.messages

当 agent_end 被 push() 时:

  1. done 变成 true;
  2. finalResultPromise 使用 agent_end.messages resolve;
  3. agent_end 事件本身仍会交给 for await 消费者。

为什么 Agent 没有使用这条 Agent EventStream

Section titled “为什么 Agent 没有使用这条 Agent EventStream”

Agent 需要在每个事件上完成两项同步工作:

更新 AgentState
→ await 所有 listeners
→ Loop 才能继续

因此它直接使用 callback 版本:

runAgentLoop(
...,
event => this.processEvents(event)
)

调用链是:

runAgentLoop
→ processEvents
├─ 更新 AgentState
└─ await listeners

如果 Agent 改用不等待消费者的 EventStream,持久化和状态副作用可能落后于 Loop。

所以两种入口服务于不同需求:

需要低层异步事件流
→ agentLoop()
→ EventStream
→ for await
需要状态、队列、取消和事件屏障
→ Agent.prompt()
→ runAgentLoop()
→ processEvents()
→ awaited listeners

一句话总结:

EventStream 是“生产者持续推送、消费者异步读取”的事件管道;listener 是 Agent 为状态更新和外部副作用提供的同步完成屏障。

flowchart TD
    Start["prompt / continue"] --> Declare["声明 SystemMessage 与工具变化"]
    Declare --> Events["消息事件与追加"] --> Prepare["prepareRequest"]
    Prepare --> Model["transformContext → convertToLlm → Provider"]
    Model --> Failed{"error / aborted?"}
    Failed -- 是 --> Observe["finishTurn,仅观察"] --> HardEnd["turn_end → agent_end"]
    Failed -- 否 --> Tools["执行工具并追加结果"] --> Finish["finishTurn"]
    Finish --> TurnEnd["turn_end"] --> EndDecision{"action=end?"}
    EndDecision -- 是 --> End["agent_end"]
    EndDecision -- 否 --> Next{"工具续轮、队列或显式 continue?"}
    Next -- 否 --> End
    Next -- 是 --> Refresh["prepareNextTurn → turn_start"] --> Declare

十八、Faux Provider 测试如何证明这条链

Section titled “十八、Faux Provider 测试如何证明这条链”

推荐精读:

  • packages/agent/test/e2e.test.ts 中的 toolExecution();
  • 同文件 executes tools and tracks pending tool calls 测试。

测试没有调用真实模型,而是给 Faux Provider 预设两条 Assistant Message:

Response 1 = text + calculate toolCall
Response 2 = final text

测试驱动:

await agent.prompt(...)

然后验证:

  1. AgentState.messages 至少包含四条消息;
  2. 存在 toolResult;
  3. Tool Result 包含真实计算值;
  4. 最后一条 Assistant Message 使用了工具结果;
  5. pendingToolCalls 最终为空;
  6. tool_execution_start 时 pending 集合包含调用 id;
  7. tool_execution_end 时该 id 已删除。

它证明了:

模型 Tool Call
→ Agent 执行本地 Tool
→ Tool Result 进入 Context
→ 模型收到结果并生成最终回答
→ Agent 状态随事件同步更新
测试位置或名称 证明内容
agent-loop.test.ts:event sequence Run、Turn、Message 的基本事件存在且有序
apply transformContext before convertToLlm Context 转换顺序
inject queued messages after all tool calls complete steering 不会插入 Tool 批次中间
emit tool_execution_end in completion order... 并行完成顺序与消息历史顺序可以不同
stop after a tool batch when every...terminate=true terminate 的批次语义
not execute tool calls from a length-truncated... 截断 Tool Call 的安全保护
agent.test.ts 中 listener settlement 测试 awaited listener 属于 Run 生命周期

本阶段不要求运行真实 E2E。Faux Provider 是确定性的,不使用 API Key,也不产生费用。

本文已经给出完整逻辑。需要核实实现时,不要顺序通读文件,按以下符号跳转:

  1. Agent.prompt():packages/agent/src/agent.ts
  2. Agent.runPromptMessages():agent.ts
  3. runAgentLoop():packages/agent/src/agent-loop.ts
  4. runLoop():agent-loop.ts
  5. streamAssistantResponse():agent-loop.ts
  6. executeToolCalls():agent-loop.ts
  7. Agent.processEvents():agent.ts
  1. AgentContext:packages/agent/src/types.ts
  2. AgentLoopConfig:types.ts
  3. Agent.createContextSnapshot():agent.ts
  4. Agent.createLoopConfig():agent.ts
  5. PendingMessageQueue:agent.ts 中 Agent 类之前
  6. runLoop() 中内外两层 while
  1. Agent.runWithLifecycle():agent.ts
  2. Agent.handleRunFailure():agent.ts
  3. prepareToolCall():agent-loop.ts
  4. executePreparedToolCall():agent-loop.ts
  5. finalizeExecutedToolCall():agent-loop.ts
  6. EventStream:packages/ai/src/utils/event-stream.ts

行号用于快速定位,后续代码变更时以函数名为准。

概念 A 概念 B 区别
Agent Run Turn 一个 Run 可以包含多个 Turn
Turn Message 一个 Turn 包含 Assistant Message 和其 Tool Results
Tool Call Tool Result 前者是模型请求,后者是宿主执行后的反馈
AgentState AgentContext 前者是 Agent 长期状态,后者是一次 Loop 的运行快照
currentContext.messages newMessages 前者含历史,后者只含当前 Run 新消息
streamingMessage messages 前者是当前 partial,后者只在 message_end 时追加完成消息
steering follow-up 前者调整当前任务,后者等待任务自然结束
error Tool Result Assistant error 前者通常可恢复并继续,后者结束当前低层 Run
agent_end Agent core idle agent_end listeners 完成且 finishRun 后才 core idle
Agent core idle agent_settled 后者还要求 AgentSession 的 retry、compaction、续跑等 post-run 工作完成
agentLoop() runAgentLoop() 前者返回 EventStream,后者使用 emit callback 返回 Promise
prompt() continue() 前者加入新消息,后者从已有 user/toolResult 尾部继续

Pi 的 Agent Loop 以消息历史为状态。每个 Turn 调用一次模型,流式构造 Assistant Message;如果模型产生 Tool Call,运行时校验并执行工具,把结果包装成 ToolResult Message 追加到 Context,再进入下一 Turn。没有 Tool Call 且消息队列为空时结束。Loop 使用内外两层循环,内层处理工具链和 steering,外层处理任务结束后的 follow-up。

Pi 把 Agent Loop 分成三层。最底层 runLoop() 负责模型与工具循环;runAgentLoop() 增加 agent、turn 和 prompt 消息事件;Agent 再增加长期状态、消息队列、取消、事件订阅和 settlement。

一次 Agent.prompt() 会先创建 Context 快照和 Loop Config。模型调用前,消息经过 transformContext 和 convertToLlm;Provider 的流式事件被归并成一个 Assistant Message。如果其中存在 Tool Call,运行时依次完成参数预处理、schema 校验、before hook、工具执行和 after hook,然后把 Tool Result 作为消息放回上下文。下一次模型请求因此能够看到真实执行结果。

内层循环负责 Tool Result 驱动的连续推理和 steering,外层循环负责 Agent 原本停止后的 follow-up。每一步都产生结构化事件,Agent.processEvents 先更新状态,再按顺序 await listeners。因此 agent_end 只是最后一个 Loop 事件,所有监听器完成并清理 activeRun 后,Agent core 才 idle;coding-agent 还要等 retry、compaction 和 queued continuation 等产品级后续工作完成,才发出 agent_settled。

使用循环实现 Tool Loop 有什么工程收益?

Section titled “使用循环实现 Tool Loop 有什么工程收益?”

从当前控制流看,循环更容易表达:

  • 可变 Context;
  • 多 Turn;
  • steering/follow-up 两种队列;
  • 明确停止条件;
  • 避免把长工具链映射成递归调用栈。

这是对实现效果的工程解释;当前源码没有把它记录为作者选择循环的正式设计决策。

为什么 Tool Result 是 Message,而不是函数返回值直接传给下一步?

Section titled “为什么 Tool Result 是 Message,而不是函数返回值直接传给下一步?”

因为模型调用是无状态请求。只有将结果序列化到消息历史,下一次请求、Session 恢复和 Provider 适配才能使用同一份上下文。

为什么并行 Tool Result 仍要按原 Tool Call 顺序写入消息?

Section titled “为什么并行 Tool Result 仍要按原 Tool Call 顺序写入消息?”

工具完成顺序受运行时间影响,不稳定;消息顺序必须与 Assistant Message 中的 Tool Call 顺序保持确定映射,才能提供稳定 Context。实时 UI 可以通过 completion-order 的 tool_execution_end 展示实际完成顺序。

保证事件副作用按订阅顺序完成,并把持久化等工作纳入 Run settlement。代价是慢 listener 会增加 Agent 完成延迟。

尝试不看本文回答:

  1. 一次 Agent Run 为什么可以包含多个 Turn?
  2. 第二个 Turn 为什么可能没有 user message?
  3. currentContext.messages 与 newMessages 分别保存什么?
  4. 为什么模型看不到 Tool 函数的直接返回值?
  5. message_update 为什么替换最后一条 partial,而不是追加消息?
  6. steering 为什么必须等当前 Tool 批次完成?
  7. follow-up 为什么需要外层循环?
  8. Tool 抛出异常后,为什么 Loop 通常仍会继续?
  9. stopReason="length" 时为什么不执行看似合法的 Tool Call?
  10. agent_end 发出后,为什么 isStreaming 可能仍为 true?
  11. 裸 agentLoop() 与 Agent 的职责差异是什么?
  12. terminate=true 为什么必须是整个 Tool 批次的一致决定?
  13. agent_end、Agent core idle 与 agent_settled 分别表示什么?
  14. 模型连续重复调用同一个 Tool 时,当前通用 runtime 会自动熔断吗?
  • 能画出 Agent.prompt() → runLoop() 调用链
  • 能解释 Run、Turn、Message 和 Tool Execution
  • 能手写内外两层循环伪代码
  • 能画出一次 Tool Call 的完整事件顺序
  • 能解释 Tool Result 回灌
  • 能解释 steering 与 follow-up 的注入时机
  • 能说明所有主要停止条件
  • 能解释错误、abort 和 listener settlement
  • 能复述 30 秒和 2 分钟面试版本

通用 Loop 的外层循环与 finishTurn 顺序没有因 MCP 接入而重写,变化集中在结果元数据与可复用工具流水线:

// streamAssistantResponse:记录本次请求使用的 thinking level。
const result = async () => Object.assign(await response.result(), {
thinkingLevel: config.reasoning ?? "off",
});
// executePreparedToolCall:成功返回也可能携带业务错误。
return { result, isError: result.isError === true };

runToolCall(call, options) 现在可单独复用 prepare/validate/before/execute/after 流水线,工具失败返回 isError 而非向调用者拒绝。它自己不发事件、不追加 Agent messages;嵌套工具的事件和持久化记录由 coding-agent 的 NestedToolCallRunner 管理。structuredContent 不属于自动发送模型的正文,after hook 只替换 content 时会丢弃旧结构化结果。该入口使 codemode 子调用继续经过已有策略 hooks。

本阶段把 Tool 执行视为 Loop 中的一个步骤:

Tool Call
→ executeToolCalls()
→ Tool Result

阶段 3 将放大这一段,研究:

Schema
→ 参数流与兼容处理
→ 校验
→ 安全 hook
→ 串行/并行调度
→ 文件 mutation queue
→ 进度与输出治理
→ Tool Result