双层 while 主循环
runLoop 是 pi-agent-core 的发动机。外层 while (true) 排空 followUp 队列,内层 while (hasMoreToolCalls || pendingMessages.length > 0) 处理工具调用循环和 steering 插话;每轮调 streamAssistantResponse 拿一条 assistant 消息,有 toolCalls 就走 executeToolCalls 再回内层,没有就退出内层去问 followUp 队列。整个循环不持有状态,所有状态由调用方 (Agent) 传入,事件通过 AgentEventSink 发出。
职责
- 事件编排:每个 turn 发
turn_start,assistant 消息发message_start/message_update/message_end,工具执行发tool_execution_*,turn 末尾发turn_end,整个循环结束发agent_end。见packages/agent/src/agent-loop.ts:155-246。 - 流式拉取 assistant 回复:
streamAssistantResponse调convertToLlm转Message[],用streamFn(默认streamSimple)发请求,for await消费事件并改写context.messages末尾的 partial message。见packages/agent/src/agent-loop.ts:252-345。 - steering 注入:内层循环开头检查
pendingMessages,把 steering 消息 push 进currentContext.messages再走下一轮 LLM 调用,见packages/agent/src/agent-loop.ts:180-188。 - followUp 接力:内层退出后,外层调
getFollowUpMessages,有消息则塞回pendingMessages再进内层,无则break。见packages/agent/src/agent-loop.ts:233-243。 - 早停:
shouldStopAfterTurn在turn_end后被调用,返回 true 时直接发agent_end退出,绕过队列轮询,见packages/agent/src/agent-loop.ts:218-228。
设计动机
为什么是两层 while 而不是一层?因为存在两种「继续」语义。工具调用是「本轮内部」的继续——assistant 发了 toolCalls,执行完要把结果喂回 LLM 让它继续说,这是内层循环。followUp 是「本轮本该结束」后的继续——用户排了队等 agent 干完再处理,这是外层循环。混在一起会让 steering(本轮插话)和 followUp(跨轮接力)的注入时机糊掉。
为什么 steering 在内层而 followUp 在外层?steering 的语义是「在下一轮 LLM 调用前插话」,所以它必须在内层 while 顶部、调 streamAssistantResponse 之前被消费。followUp 是「agent 本来要停了再塞一个任务」,所以要等内层自然退出(hasMoreToolCalls=false 且 steering 空)再检查。
为什么 streamAssistantResponse 直接改 context.messages?因为 partial message 在流式期间就要让 UI 看到,而 LLM 调用又要基于「上一条 assistant 已写入」的 context。把 partial 推进 context 是最简单的做法,done 事件再把 partial 替换成 finalMessage。代价是 context 在流式中处于「半成品」状态,但循环只读不写,不会有竞态。
关键文件
packages/agent/src/agent-loop.ts:25-26—AgentEventSink类型,所有事件的同步入口。packages/agent/src/agent-loop.ts:31-54—agentLoop同步入口,内部void runAgentLoop(...).then(stream.end),返回EventStream。packages/agent/src/agent-loop.ts:64-93—agentLoopContinue不加新 prompt,从现有 context 接续,用于重试。packages/agent/src/agent-loop.ts:95-118—runAgentLoop发agent_start/turn_start,把 prompt 消息逐条message_start/message_end发出,再调runLoop。packages/agent/src/agent-loop.ts:120-143—runAgentLoopContinue不发 prompt 消息事件,直接进runLoop。packages/agent/src/agent-loop.ts:155-246—runLoop主体:外层 while、内层 while、错误短路、shouldStopAfterTurn、followUp 接力。packages/agent/src/agent-loop.ts:252-345—streamAssistantResponse:transformContext→convertToLlm→streamFunction→for await事件循环。packages/agent/src/agent-loop.ts:281-285— 真正的 stream 调用,apiKey在调用前通过getApiKey重新解析,避免长任务里 OAuth token 过期。
外层排 followUp、内层处理工具调用的骨架:
// packages/agent/src/agent-loop.ts:168-243
while (true) {
let hasMoreToolCalls = true;
while (hasMoreToolCalls || pendingMessages.length > 0) {
if (!firstTurn) await emit({ type: "turn_start" }); else firstTurn = false;
// 把 steering 推进 context ...
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);
if (message.stopReason === "error" || message.stopReason === "aborted") { /* 发 turn_end/agent_end 返回 */ }
const toolCalls = message.content.filter((c) => c.type === "toolCall");
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit);
toolResults.push(...executedToolBatch.messages);
hasMoreToolCalls = !executedToolBatch.terminate;
// 把 toolResults 推进 context ...
}
await emit({ type: "turn_end", message, toolResults });
if (await config.shouldStopAfterTurn?.(...)) { await emit({ type: "agent_end", ... }); return; }
pendingMessages = (await config.getSteeringMessages?.()) || [];
}
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) { pendingMessages = followUpMessages; continue; }
break;
}streamAssistantResponse 在 stream 事件里直接改 context.messages 末尾的 partial message:
// packages/agent/src/agent-loop.ts:290-316
for await (const event of response) {
switch (event.type) {
case "start":
partialMessage = event.partial;
context.messages.push(partialMessage);
addedPartial = true;
await emit({ type: "message_start", message: { ...partialMessage } });
break;
case "text_start": case "text_delta": case "text_end":
case "thinking_start": case "thinking_delta": case "thinking_end":
case "toolcall_start": case "toolcall_delta": case "toolcall_end":
if (partialMessage) {
partialMessage = event.partial;
context.messages[context.messages.length - 1] = partialMessage;
await emit({ type: "message_update", assistantMessageEvent: event, message: { ...partialMessage } });
}
break;
case "done": case "error": { /* 替换为 finalMessage,发 message_end,返回 */ }
}
}数据流
agent.prompt(text) 进来后的完整路径:
边界与失败
- 错误短路:
stopReason为"error"或"aborted"时,直接发turn_end+agent_end返回,不再执行工具、不询问 steering/followUp,见packages/agent/src/agent-loop.ts:194-198。 - transformContext 契约:
transformContext抛错会中断循环且不产生正常事件序列,因此类型契约要求它不能 throw,只能返回 fallback,见packages/agent/src/agent-loop.ts:260-263和 类型契约。 - steering 首轮跳过:
runPromptMessages在调runAgentLoop时可传skipInitialSteeringPoll,避免 prompt 刚发出就被 steering 抢断,见packages/agent/src/agent.ts:374-388。 - continue 角色前置校验:
runAgentLoopContinue/agentLoopContinue都在调runLoop前检查末条消息不能是 assistant,否则抛错,见packages/agent/src/agent-loop.ts:127-133。 - partial 不进 newMessages:partial message 写进
currentContext.messages用于下一轮 LLM 上下文,但newMessages(返回给调用方的本轮新增)只在message_end后 push 完整 finalMessage,见packages/agent/src/agent-loop.ts:191-192。
小结
runLoop 用双层 while 把「本轮工具调用循环」和「跨轮 followUp 接力」分开,steering 在内层顶部注入,followUp 在外层末尾接力,错误和 abort 短路退出。循环本身无状态,所有跨轮信息靠 Agent 传入的 context 和 getSteeringMessages/getFollowUpMessages 两个回调。工具调用怎么具体跑,看 工具执行 sequential/parallel;循环的入口封装和状态壳,看 Agent 类与生命周期。