最小 Agent Loop 源码导读
完成本阶段后,不需要逐行阅读源码,也应当能够:
- 解释一次
Agent.prompt()如何变成多次模型调用; - 区分 Agent Run、Turn、Message 和 Tool Execution;
- 手写 Pi Agent Loop 的核心伪代码;
- 解释为什么 Loop 使用内外两层循环;
- 说明 Tool Result 为什么必须回传给模型;
- 说明 steering 和 follow-up 的注入时机;
- 画出完整事件顺序;
- 解释错误、取消和 listener settlement。
阶段 1 回答“系统由什么组成”。本阶段只聚焦最核心的动态行为:
用户消息 → 模型响应 → Tool Call → Tool Result → 再次调用模型 → 最终文本回答一、先建立最小心智模型
Section titled “一、先建立最小心智模型”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 本次新增的 messagesPi 的真实实现还必须处理:
- 流式输出;
- Tool 参数校验;
- 多个 Tool Call;
- 并行或串行执行;
- Tool 执行进度;
- steering 和 follow-up;
- abort;
- 模型错误;
- Context 转换;
- 每一步的事件;
- 事件监听器完成后 Agent core 才 idle,并区分更上层的
agent_settled。
因此,真实结构是:
Agent └─ runWithLifecycle() └─ runAgentLoop() └─ runLoop() ├─ streamAssistantResponse() ├─ executeToolCalls() ├─ inner loop └─ outer loop二、五个必须区分的概念
Section titled “二、五个必须区分的概念”1. Agent Run
Section titled “1. Agent Run”一次 Agent.prompt() 或 Agent.continue() 启动一次 Agent Run。
边界事件:
agent_start ...agent_end一个 Run 可以包含多个 Turn。例如模型先调用 read,看到结果后调用 edit,最后回答用户,这仍然是一个 Run。
为什么需要 Run
Section titled “为什么需要 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_endlisteners 完成、activeRun被清理的一次执行生命周期。
需要注意:Run 结束不保证 steering/follow-up 队列一定被清空。例如 finishTurn 会在读取这两个队列前结束当前 Run,排队消息仍可留给后续处理。
2. Turn
Section titled “2. Turn”一个 Turn 是:
一次 Assistant Message,以及该消息中所有 Tool Call 的执行结果。
边界事件:
turn_start → Assistant Message → 零个或多个 Tool Execution → 零个或多个 ToolResult Messageturn_endTurn 不是“一个 user + 一个 assistant”。第二个 Turn 可能没有新的用户输入,而是由上一轮 Tool Result 自动触发。
为什么工具执行属于 Turn
Section titled “为什么工具执行属于 Turn”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 中的一次行动3. Message
Section titled “3. Message”Loop 主要处理三种模型可理解的消息:
system:Prompt sections 与工具声明的历史更新;user:用户输入;assistant:模型文本、thinking 和 Tool Call;toolResult:工具执行结果。
AgentMessage 允许宿主扩展自定义消息,但在调用模型前必须通过 convertToLlm() 转换或过滤。
4. Tool Execution
Section titled “4. Tool Execution”Assistant Message 中的一个 toolCall content block 对应一次 Tool Execution。
它经历:
tool_execution_start → 参数预处理与校验 → beforeToolCall → tool.execute() → 若干 tool_execution_update → afterToolCalltool_execution_end然后执行结果会被包装成独立的 toolResult Message。
5. Provider Stream Event
Section titled “5. Provider Stream Event”模型 Provider 返回的不是直接完成的 Assistant Message,而是一串流式事件:
starttext_start / text_delta / text_endthinking_start / thinking_delta / thinking_endtoolcall_start / toolcall_delta / toolcall_enddone / errorstreamAssistantResponse() 将这些 Provider 事件归并为一个不断更新、最终完成的 Assistant Message。
正常流以 start 开始,并由最终的 done/error 收束;text、thinking、toolcall 事件按本次响应实际内容出现,并不是每次响应都会完整经过全部中间事件。调用模型前 signal 已 aborted 等边界下,Provider 也可能直接发出 error 而没有 start,streamAssistantResponse() 会为最终消息补齐 Agent 层的 message_start/message_end。
三、核心类型与状态
Section titled “三、核心类型与状态”关键定义集中在 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 与 AgentContext 的区别
Section titled “AgentState 与 AgentContext 的区别”AgentState 是 Agent 长期持有的可变状态:
AgentState├─ systemPrompt(只读 getter,由 messages 重放得到)├─ model├─ thinkingLevel├─ tools├─ messages├─ isStreaming├─ streamingMessage├─ pendingToolCalls└─ errorMessageAgentContext 是启动一次 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 包装层的额外检查误写成所有入口共有。
currentContext 与 newMessages
Section titled “currentContext 与 newMessages”runLoop() 同时维护两个消息数组:
| 变量 | 内容 | 用途 |
|---|---|---|
currentContext.messages |
历史消息 + 本次新增消息 | 作为下一次模型调用的上下文 |
newMessages |
仅本次 Run 新产生的消息 | 用于 agent_end.messages 和函数返回值 |
例如历史已有 20 条消息,且工具和 Prompt 无变化,本次新增:
userassistant(toolCall)toolResultassistant(final)那么:
currentContext.messages最终有 24 条;newMessages只有这次新增的 4 条。
这避免每次 agent_end 都重复返回整个历史。若本次还声明了 Prompt 或工具变化,新增的 SystemMessage 也要计入数量。
四、三层入口:应该从哪里开始读
Section titled “四、三层入口:应该从哪里开始读”Pi 同时暴露了三种粒度的入口。
第一层:agentLoop()
Section titled “第一层:agentLoop()”源码位置: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)。
第二层:runAgentLoop()
Section titled “第二层:runAgentLoop()”源码位置:packages/agent/src/agent-loop.ts
它接受一个 emit(event) 回调,直接返回 Promise<AgentMessage[]>。
它负责:
declareToolChanges(context, prompts)声明工具差量,再复制到newMessages;- 把历史 Context 和 prompt 合并为
currentContext; - 发出
agent_start; - 发出第一个
turn_start; - 为 prompt 发出
message_start/message_end; - 进入共享的
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)第三层:Agent
Section titled “第三层:Agent”源码位置: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() 进入源码
Section titled “五、从 Agent.prompt() 进入源码”步骤 1:阻止并发 Run
Section titled “步骤 1:阻止并发 Run”关键逻辑:Agent.prompt(),agent.ts
如果 activeRun 已存在,prompt() 会报错:
Agent is already processing a prompt.Use steer() or followUp() ...原因是同一个 AgentState.messages 不能被两个 Loop 并发修改。用户在 Agent 工作过程中继续输入,必须明确选择:
steer:尽快影响当前工作;followUp:等当前工作自然完成后再处理。
activeRun 属于哪个范围
Section titled “activeRun 属于哪个范围”activeRun 是每个 Agent 实例自己的私有字段,不是进程级或全局锁:
AgentSession A└─ Agent A └─ activeRun A
AgentSession B└─ Agent B └─ activeRun B两个 Session 通常各自持有一个 Agent,因此可以同时运行,形成两个独立的 Run。
限制只发生在同一个 Agent:
Agent A.prompt("任务 1") → 创建 activeRunAgent 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()。
步骤 2:规范化用户输入
Section titled “步骤 2:规范化用户输入”normalizePromptInput() 将字符串变成:
{ role: "user", content: [ { type: "text", text: input }, ...images ], timestamp}如果调用者已经传入 AgentMessage 或数组,则直接使用。
步骤 3:建立运行生命周期
Section titled “步骤 3:建立运行生命周期”调用链:
prompt() → runPromptMessages() → runWithLifecycle() → runAgentLoop()关键逻辑:Agent.runWithLifecycle(),agent.ts
它在 Loop 外围完成:
创建 AbortController创建 activeRun.promiseisStreaming = true清空 streamingMessage 和 errorMessage
try: 执行 runAgentLoop()catch: 生成规范化失败事件finally: isStreaming = false 清理 pendingToolCalls resolve activeRun.promise这里的 isStreaming 实际表示“Agent Run 仍活跃”,不仅表示模型正在输出文本。工具执行和 awaited listeners 尚未完成时,它也保持为 true。
步骤 4:创建 Context 与 Config
Section titled “步骤 4:创建 Context 与 Config”关键逻辑:
createContextSnapshot():agent.tscreateLoopConfig():agent.ts
Context 是运行数据:
messages(含 system)+ tools(可执行实现)Config 是运行行为:
modelthinking levelstream optionstool execution modeconvertToLlm / transformContextbeforeToolCall / afterToolCallfinishTurn / prepareNextTurn / prepareRequestgetSteeringMessagesgetFollowUpMessages这种拆分意味着:
- Context 回答“模型本轮能看到什么”;
- Config 回答“Loop 应该如何运行”。
六、runLoop():整个项目的核心
Section titled “六、runLoop():整个项目的核心”关键逻辑: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 否则 breakemit(agent_end)为什么 hasMoreToolCalls 初始为 true
Section titled “为什么 hasMoreToolCalls 初始为 true”进入内层循环时,还没有 Assistant Message。将它初始化为 true,是为了保证至少执行一次模型调用。
完成一次模型调用后:
- 没有 Tool Call:设为
false,内层循环准备结束; - 有 Tool Call:通常保持为
true,Tool Result 会触发下一次模型调用; - 所有 Tool Result 都要求
terminate:设为false,不再自动调用模型。
为什么是内外两层循环
Section titled “为什么是内外两层循环”内层循环负责“当前任务仍在推进”:
- Tool Call 需要模型继续;
- steering 消息需要尽快注入。
外层循环负责“任务原本已经结束,但用户安排了后续任务”:
- 当前 Assistant 已经给出最终回答;
- 没有 Tool Call;
- 没有 steering;
- 此时才检查 follow-up。
如果只有一层循环,steering 和 follow-up 就很难表达不同的调度语义。
Turn 与 steering 的精确边界
Section titled “Turn 与 steering 的精确边界”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 AtoolResult BtoolResult Cuser steering下一次 Assistant Message不能变成:
assistant(tool A, tool B, tool C)toolResult Auser steeringtoolResult BtoolResult C后一种排列把新的用户输入插入了一个尚未完成的 Tool 批次,会破坏 Assistant Tool Call 与 Tool Result 的完整对应关系。
后面的对话与事件案例为突出 User/Assistant/ToolResult,省略无变化的系统基线。若 Prompt 或工具集合发生变化,还会先发出 SystemMessage 的 message_start/message_end 并计入新增消息。
七、正常文本回答的完整流程
Section titled “七、正常文本回答的完整流程”假设模型不调用工具,直接回答“完成”。
初始 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_startturn_startmessage_start(user)message_end(user)message_start(assistant)message_update(assistant) × Nmessage_end(assistant)turn_endagent_end八、带 Tool Call 的完整流程
Section titled “八、带 Tool Call 的完整流程”假设用户要求计算 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_startmessage_start(user)message_end(user)message_start(assistant with toolCall)message_update(...) × Nmessage_end(assistant)tool_execution_starttool_execution_update × Ntool_execution_endmessage_start(toolResult)message_end(toolResult)turn_end
turn_startmessage_start(assistant final)message_update(...) × Nmessage_end(assistant final)turn_end
agent_end注意第二个 Turn 没有新的 user message。它由 Tool Result 自动触发。
九、模型流如何变成 Assistant Message
Section titled “九、模型流如何变成 Assistant Message”关键逻辑:streamAssistantResponse(),agent-loop.ts
模型调用前的数据转换
Section titled “模型调用前的数据转换”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.messagesemit message_start收到 text/thinking/toolcall 的增量事件:
partialMessage = event.partial替换 currentContext.messages 的最后一条emit message_update收到 done 或 error:
finalMessage = await response.result()用 finalMessage 替换最后一条 partialemit message_endreturn finalMessage为什么是“替换最后一条”,而不是每个 delta 都追加一条消息?
因为 delta 只是同一个 Assistant Message 的不同快照。Context 中应该始终只有一条正在增长的 Assistant Message,否则模型历史会被拆成大量重复消息。
十、Tool Call 流水线概览
Section titled “十、Tool Call 流水线概览”阶段 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 = toolResultisError = truecontent = 错误原因模型因此可以修正参数、换一种工具或向用户解释失败。
这是 Agent 系统的重要原则:
可恢复的工具失败应进入模型上下文,而不是直接终止整个 Agent Run。
stopReason === "length" 的特殊保护
Section titled “stopReason === "length" 的特殊保护”如果模型输出因 token 上限被截断,Tool Call 参数可能只生成了一部分。即使残缺 JSON 恰好能够解析和通过 schema,也不能安全执行。
因此 failToolCallsFromTruncatedMessage() 会:
- 不执行该 Assistant Message 中的任何 Tool Call;
- 为每个调用产生错误 Tool Result;
- 让 Loop 继续;
- 让模型重新发出完整调用。
terminate 的含义
Section titled “terminate 的含义”工具结果可以返回:
terminate: true它表示“不需要为了这个工具结果自动再调用一次模型”。
只有一个批次中的所有结果都设置 terminate: true,shouldTerminateToolBatch() 才返回 true:
finalizedCalls.length > 0 ANDevery result.terminate === true混合批次中,只要有一个工具仍需要模型处理,就满足工具自动续轮条件;随后 finishTurn 仍可返回 end。
即使整个批次都 terminate: true,它也只是把“Tool Result 自动续轮”关闭。Loop 随后仍会检查 steering 和 follow-up;队列消息或 finishTurn 的显式 continue 都可能让当前 Run 进入下一 Turn;finishTurn 的 end 则优先结束。
十一、steering 与 follow-up
Section titled “十一、steering 与 follow-up”steering
Section titled “steering”含义:
用户希望新消息尽快影响当前任务,但不破坏正在执行的 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 协议。
follow-up
Section titled “follow-up”含义:
当前任务自然完成后,再处理这条消息。
检查时机:
没有更多 Tool CallAND steering queue 为空 → Agent 原本准备结束 → drain follow-up queue → 若有消息,重新进入内层循环| 维度 | steering | follow-up |
|---|---|---|
| 目标 | 调整当前工作方向 | 排队执行后续任务 |
| 检查时机 | 每个 Turn 结束后 | Agent 原本准备停止时 |
| 是否等待当前 Tool 批次 | 是 | 是 |
| 是否等待整个当前任务自然结束 | 否 | 是 |
| 使用哪层循环 | 内层循环 | 外层循环 |
QueueMode
Section titled “QueueMode”队列有两种 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。
下一 Turn 的准备
Section titled “下一 Turn 的准备”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 的刷新发生在请求边界,不会切换正在输出的流。
每次请求的准备
Section titled “每次请求的准备”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。
十三、所有停止条件
Section titled “十三、所有停止条件”| 条件 | 发生位置 | 结果 |
|---|---|---|
| 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 边界优雅停止。
十四、错误与取消
Section titled “十四、错误与取消”模型协议内错误
Section titled “模型协议内错误”StreamFn 的约定是:
- 请求、模型或运行时错误不应直接 reject;
- 应通过流式
error事件和最终stopReason="error"表达。
streamAssistantResponse() 因此仍能生成完整的 message_end。
runLoop() 看到 error/aborted 后发出:
turn_endagent_endLoop 外意外异常
Section titled “Loop 外意外异常”如果 StreamFn、Context hook、listener 或其他代码违反“不抛出”的约定,Agent.runWithLifecycle() 会捕获第一次异常。
handleRunFailure() 人工构造一条 Assistant Message:
role: assistantcontent: 空文本stopReason: error 或 abortederrorMessage: 异常信息然后尝试补齐标准事件:
message_startmessage_endturn_endagent_end正常情况下,这让 UI 和持久化层仍能收到统一的失败生命周期。这里不是绝对保证:如果 listener 在处理这些失败事件时再次抛出,补偿事件序列本身也可能中断;finally 仍会执行 finishRun(),确保运行状态被清理。
Agent.abort() 调用当前 AbortController.abort()。
同一个 signal 会传给:
streamFunction;transformContext;beforeToolCall;tool.execute;afterToolCall;- awaited event listeners。
是否能立即终止取决于每个异步实现是否尊重 signal。
十五、事件如何更新 AgentState
Section titled “十五、事件如何更新 AgentState”关键逻辑:Agent.processEvents(),agent.ts
listener 是什么
Section titled “listener 是什么”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 之前
Section titled “状态更新发生在 listener 之前”它先更新内部状态,再通知 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。
为什么 listener 必须 await
Section titled “为什么 listener 必须 await”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() 已清理 activeRunagent_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 的传输容器。
为什么需要一个事件流
Section titled “为什么需要一个事件流”模型和 Agent 都不是一次性返回结果:
普通函数:调用函数 ───────────────→ 最终结果
流式函数:调用函数 → start → delta → delta → toolCall → ... → 最终结果生产者不断产生事件,消费者希望事件一出现就处理,而不是等全部完成。两者运行速度也可能不同:
生产者:快速产生 event A、B、C消费者:还在处理 event AEventStream 位于两者中间:
生产者 EventStream 消费者Provider / Agent Loop → queue + waiting → for await它同时解决:
- 尚未被消费的事件放在哪里;
- 消费者等待下一事件时由谁唤醒;
- 如何判断流已经结束;
- 如何在事件之外取得最终聚合结果。
Pi 中实际有两条 EventStream
Section titled “Pi 中实际有两条 EventStream”第一条:Provider 的消息流
Section titled “第一条:Provider 的消息流”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();第二条:低层 Agent 生命周期流
Section titled “第二条:低层 Agent 生命周期流”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 风格。
queue 和 waiting 分别是什么
Section titled “queue 和 waiting 分别是什么”关键逻辑: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 与 result() 的区别
Section titled “for await 与 result() 的区别”for await 得到过程:
agent_startmessage_updatetool_execution_start...agent_endresult() 得到最终聚合值:
Agent EventStream → 本次 Run 的 AgentMessage[]Provider EventStream → 最终 AssistantMessage对于 Agent Loop,构造 EventStream 时声明:
完成事件:event.type === "agent_end"最终结果:agent_end.messages当 agent_end 被 push() 时:
done变成 true;finalResultPromise使用agent_end.messagesresolve;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为状态更新和外部副作用提供的同步完成屏障。
十七、完整流程图
Section titled “十七、完整流程图”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 toolCallResponse 2 = final text测试驱动:
await agent.prompt(...)然后验证:
AgentState.messages至少包含四条消息;- 存在
toolResult; - Tool Result 包含真实计算值;
- 最后一条 Assistant Message 使用了工具结果;
pendingToolCalls最终为空;tool_execution_start时 pending 集合包含调用 id;tool_execution_end时该 id 已删除。
它证明了:
模型 Tool Call → Agent 执行本地 Tool → Tool Result 进入 Context → 模型收到结果并生成最终回答 → Agent 状态随事件同步更新其他关键测试及其证明目标
Section titled “其他关键测试及其证明目标”| 测试位置或名称 | 证明内容 |
|---|---|
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,也不产生费用。
十九、推荐源码回查顺序
Section titled “十九、推荐源码回查顺序”本文已经给出完整逻辑。需要核实实现时,不要顺序通读文件,按以下符号跳转:
第一遍:只理解主干
Section titled “第一遍:只理解主干”Agent.prompt():packages/agent/src/agent.tsAgent.runPromptMessages():agent.tsrunAgentLoop():packages/agent/src/agent-loop.tsrunLoop():agent-loop.tsstreamAssistantResponse():agent-loop.tsexecuteToolCalls():agent-loop.tsAgent.processEvents():agent.ts
第二遍:理解状态和调度
Section titled “第二遍:理解状态和调度”AgentContext:packages/agent/src/types.tsAgentLoopConfig:types.tsAgent.createContextSnapshot():agent.tsAgent.createLoopConfig():agent.tsPendingMessageQueue:agent.ts中Agent类之前runLoop()中内外两层 while
第三遍:理解失败和边界
Section titled “第三遍:理解失败和边界”Agent.runWithLifecycle():agent.tsAgent.handleRunFailure():agent.tsprepareToolCall():agent-loop.tsexecutePreparedToolCall():agent-loop.tsfinalizeExecutedToolCall():agent-loop.tsEventStream:packages/ai/src/utils/event-stream.ts
行号用于快速定位,后续代码变更时以函数名为准。
二十、容易混淆的概念
Section titled “二十、容易混淆的概念”| 概念 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 尾部继续 |
二十一、面试表达
Section titled “二十一、面试表达”30 秒版本
Section titled “30 秒版本”Pi 的 Agent Loop 以消息历史为状态。每个 Turn 调用一次模型,流式构造 Assistant Message;如果模型产生 Tool Call,运行时校验并执行工具,把结果包装成 ToolResult Message 追加到 Context,再进入下一 Turn。没有 Tool Call 且消息队列为空时结束。Loop 使用内外两层循环,内层处理工具链和 steering,外层处理任务结束后的 follow-up。
2 分钟版本
Section titled “2 分钟版本”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 展示实际完成顺序。
为什么 listener 要串行 await?
Section titled “为什么 listener 要串行 await?”保证事件副作用按订阅顺序完成,并把持久化等工作纳入 Run settlement。代价是慢 listener 会增加 Agent 完成延迟。
二十二、本阶段理解检查
Section titled “二十二、本阶段理解检查”尝试不看本文回答:
- 一次 Agent Run 为什么可以包含多个 Turn?
- 第二个 Turn 为什么可能没有 user message?
currentContext.messages与newMessages分别保存什么?- 为什么模型看不到 Tool 函数的直接返回值?
message_update为什么替换最后一条 partial,而不是追加消息?- steering 为什么必须等当前 Tool 批次完成?
- follow-up 为什么需要外层循环?
- Tool 抛出异常后,为什么 Loop 通常仍会继续?
stopReason="length"时为什么不执行看似合法的 Tool Call?agent_end发出后,为什么isStreaming可能仍为 true?- 裸
agentLoop()与Agent的职责差异是什么? terminate=true为什么必须是整个 Tool 批次的一致决定?agent_end、Agent core idle 与agent_settled分别表示什么?- 模型连续重复调用同一个 Tool 时,当前通用 runtime 会自动熔断吗?
二十三、本阶段验收
Section titled “二十三、本阶段验收”- 能画出
Agent.prompt() → runLoop()调用链 - 能解释 Run、Turn、Message 和 Tool Execution
- 能手写内外两层循环伪代码
- 能画出一次 Tool Call 的完整事件顺序
- 能解释 Tool Result 回灌
- 能解释 steering 与 follow-up 的注入时机
- 能说明所有主要停止条件
- 能解释错误、abort 和 listener settlement
- 能复述 30 秒和 2 分钟面试版本
当前新增的结果与复用入口
Section titled “当前新增的结果与复用入口”通用 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。
下一阶段连接
Section titled “下一阶段连接”本阶段把 Tool 执行视为 Loop 中的一个步骤:
Tool Call → executeToolCalls() → Tool Result阶段 3 将放大这一段,研究:
Schema → 参数流与兼容处理 → 校验 → 安全 hook → 串行/并行调度 → 文件 mutation queue → 进度与输出治理 → Tool Result