focus/docs/architecture/02-agent-loop.md

731 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 逐行精读:`agent-loop.ts` —— 核心循环Pi 的心脏)
> 源文件:`pi-main/packages/agent/src/agent-loop.ts`792 行)
>
> 前置阅读:[01-types-and-protocol.md](./01-types-and-protocol.md)types.ts 逐行精读)。
> 循环只是在操作那些类型——不懂类型就读不懂循环。
## 文件结构总览
```
agent-loop.ts (792 行)
├── 公共入口L1-151
│ ├── agentLoop() —— 带 prompt 启动
│ ├── agentLoopContinue() —— 不加 prompt 续跑(用于重试)
│ └── createAgentStream() —— EventStream 工厂
├── 核心循环 runLoop()L155-275⭐⭐⭐
│ ├── 外层 whilefollow-up 循环
│ └── 内层 whilesteering + 工具循环
├── streamAssistantResponse()L281-374⭐⭐ 边界转换
├── 工具执行L383-755
│ ├── failToolCallsFromTruncatedMessage() —— 截断时全部失败
│ ├── executeToolCalls() —— 模式选择
│ ├── executeToolCallsSequential() —— 顺序执行
│ ├── executeToolCallsParallel() —— 并发执行
│ ├── prepareToolCall() —— 验证 + before hook
│ ├── executePreparedToolCall() —— 执行 + 流式更新
│ └── finalizeExecutedToolCall() —— after hook
└── 辅助函数L757-792
├── createErrorToolResult()
├── emitToolExecutionEnd()
├── createToolResultMessage()
└── emitToolResultMessage()
```
---
## 一、公共入口L1-151
### `agentLoop` —— 带 prompt 启动L34-118
```ts
// L34-101签名
export function agentLoop(
prompts: AgentMessage[],
context: AgentContext,
config: AgentLoopConfig,
signal?: AbortSignal,
streamFn?: StreamFn,
): EventStream<AgentEvent, AgentMessage[]> {
const stream = createAgentStream();
void runAgentLoop(prompts, context, config, async (event) => stream.push(event), signal, streamFn)
.then((messages) => stream.end(messages));
return stream;
}
```
**关键模式:立即返回 EventStream后台异步推事件**。`void runAgentLoop(...).then(...)` 启动一个不等待的 promise事件通过 `stream.push` 推给消费者。这让调用方能边迭代事件边让循环跑——**生产者/消费者解耦**。
```ts
// L103-117runAgentLoop 主体)
async function runAgentLoop(...): Promise<AgentMessage[]> {
const newMessages: AgentMessage[] = [...prompts]; // 本次 run 产生的所有新消息
const currentContext: AgentContext = {
...context,
messages: [...context.messages, ...prompts], // 上下文 = 原有 + 新 prompt
};
await emit({ type: "agent_start" }); // L109 生命周期开始
await emit({ type: "turn_start" }); // L110
for (const prompt of prompts) { // L111-114 为每个 prompt 发 message 事件
await emit({ type: "message_start", message: prompt });
await emit({ type: "message_end", message: prompt });
}
await runLoop(currentContext, newMessages, config, signal, emit, streamFn); // L116 ← 核心循环
return newMessages; // L117 返回本次产生的新消息
}
```
**`newMessages` 是只追加的列表**——本次 run 产生的所有新消息prompt + assistant + toolResult + steering + follow-up。它和 `currentContext.messages`(完整 transcript是两个不同的东西。
### `agentLoopContinue` —— 续跑L120-143
```ts
// L127-133 两个前置校验
if (context.messages.length === 0) throw new Error("Cannot continue: no messages in context");
if (context.messages[last].role === "assistant") throw new Error("Cannot continue from assistant");
```
**为什么最后一条不能是 assistant** 因为继续循环意味着要调 LLM而 LLM 调用要求最后一条是 user 或 toolResultAnthropic/OpenAI 的协议约束)。最后一条是 assistant 时"继续"无意义——你应该用 `prompt` 加新消息。
### `createAgentStream`L145-150
```ts
function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {
return new EventStream<AgentEvent, AgentMessage[]>(
(event) => event.type === "agent_end", // 完成检测器
(event) => event.type === "agent_end" ? event.messages : [], // 结果提取器
);
}
```
`EventStream<T, R>` 的两个回调:① 判断哪个事件是"完成"事件;② 从完成事件提取最终结果 `R`。这里完成事件是 `agent_end`,结果是它的 `messages`
---
## 二、核心循环 `runLoop`L155-275⭐⭐⭐
这是整个文件最重要的一段。**双层循环 + 四个停止条件**。
### 函数签名与初始状态L155-167
```ts
async function runLoop(
initialContext: AgentContext,
newMessages: AgentMessage[],
initialConfig: AgentLoopConfig,
signal: AbortSignal | undefined,
emit: AgentEventSink,
streamFn?: StreamFn,
): Promise<void> {
let currentContext = initialContext; // 可变prepareNextTurn 会替换
let config = initialConfig; // 可变prepareNextTurn 会替换 model/reasoning
let firstTurn = true;
// L167 启动时就检查 steering用户可能在等待时已输入
let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
```
**`currentContext``config` 都是 `let`**——`prepareNextTurn` 可以整体替换它们。这是"每轮重建上下文"的实现基础。
**L167 的 `getSteeringMessages` 在循环开始就调用**——用户可能在 `agentLoop` 返回 EventStream 后、循环真正开始前就输入了 steering 消息。这是为了不丢消息。
### 双层循环骨架L170-272
```ts
// L170 外层 whilefollow-up 循环
while (true) {
let hasMoreToolCalls = true;
// L174 内层 whilesteering + 工具循环
while (hasMoreToolCalls || pendingMessages.length > 0) {
```
**为什么是双层?**
- **内层**:处理"一个连贯的工作流"——有工具调用就继续,有 steering 就注入。
- **外层**:当内层要退出(没工具调用也没 steering 了),再检查 follow-up——"任务做完了,还有别的吗?"有就重启内层。
这种分层让 steering紧急插话和 follow-up后续任务的语义清晰分离。
### 内层循环体L175-260
#### ① Turn 标记与消息注入L175-190
```ts
if (!firstTurn) {
await emit({ type: "turn_start" }); // L176 非首轮才发 turn_start
} else {
firstTurn = false; // 首轮的 turn_start 已在 runAgentLoop 发过
}
// L182-190 注入 pendingMessagessteering 或 follow-up
if (pendingMessages.length > 0) {
for (const message of pendingMessages) {
await emit({ type: "message_start", message });
await emit({ type: "message_end", message });
currentContext.messages.push(message); // 加入 transcript
newMessages.push(message); // 加入本次 run 的新消息
}
pendingMessages = []; // 清空
}
```
**steering/follow-up 消息在调 LLM **之前**注入**——模型会在下一轮看到它们。
#### ② 流式接收 assistant 响应L193-200
```ts
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);
newMessages.push(message);
// L196-200 错误/中止:直接结束
if (message.stopReason === "error" || message.stopReason === "aborted") {
await emit({ type: "turn_end", message, toolResults: [] });
await emit({ type: "agent_end", messages: newMessages });
return;
}
```
**这是停止条件①**provider 返回 error/aborted。注意仍然发 `turn_end``agent_end`——保证事件序列完整。
#### ③ 提取并执行工具调用L202-222
```ts
const toolCalls = message.content.filter((c) => c.type === "toolCall");
const toolResults: ToolResultMessage[] = [];
hasMoreToolCalls = false; // L206 默认假设不继续
if (toolCalls.length > 0) {
// L208-214 关键分支:截断时全部失败
const executedToolBatch =
message.stopReason === "length"
? await failToolCallsFromTruncatedMessage(toolCalls, emit)
: await executeToolCalls(currentContext, message, config, signal, emit);
toolResults.push(...executedToolBatch.messages);
hasMoreToolCalls = !executedToolBatch.terminate; // L216 terminate 决定是否继续
for (const result of toolResults) {
currentContext.messages.push(result); // 工具结果入 transcript
newMessages.push(result);
}
}
```
**`length` 停止原因的特殊处理**L208-214——这是停止条件的一个微妙点
为什么截断时要全部失败?因为流式 tool call 的参数是用**增量 JSON 解析**`parseStreamingJson`)拼出来的。如果输出被 token 限制截断,拼出来的 JSON 可能恰好能解析、能通过 schema 验证,但**内容是残缺的**——执行它会有不可预测的后果。所以宁可全部报错让模型重新发。
`hasMoreToolCalls = !executedToolBatch.terminate`L216——**只有批内所有结果都 `terminate: true` 才停止**(见 `shouldTerminateToolBatch`L584-586
#### ④ prepareNextTurn + shouldStopAfterTurnL224-259
```ts
await emit({ type: "turn_end", message, toolResults });
// L226-245 prepareNextTurn每轮后重建上下文
const nextTurnContext = { message, toolResults, context: currentContext, newMessages };
const nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);
if (nextTurnSnapshot) {
currentContext = nextTurnSnapshot.context ?? currentContext; // 替换上下文
config = {
...config,
model: nextTurnSnapshot.model ?? config.model, // 替换模型
reasoning: nextTurnSnapshot.thinkingLevel === undefined // 替换思考级别
? config.reasoning
: nextTurnSnapshot.thinkingLevel === "off" ? undefined : nextTurnSnapshot.thinkingLevel,
};
}
// L247-257 停止条件②shouldStopAfterTurn
if (await config.shouldStopAfterTurn?.({ message, toolResults, context: currentContext, newMessages })) {
await emit({ type: "agent_end", messages: newMessages });
return;
}
pendingMessages = (await config.getSteeringMessages?.()) || []; // L259 再 poll steering
} // 内层 while 结束
```
**`prepareNextTurn` 是 harness 层动态上下文的钩子**L226-245。harness 在这里:从 session 重载消息、重算系统提示(可含 cwd/git status、重建工具列表。模型切换、上下文压缩、工具动态加载都通过这个生效。
**L247-257 是停止条件②**`shouldStopAfterTurn` 返回 true。注意它**在 steering poll 之前**——如果要求停止,就不 poll steering/follow-up 了L255 直接 return。用途上下文快满了提前停。
**L259 再次 poll steering**——内层循环每次 turn 结束都检查。这就是 steering 的"紧急插话"语义。
### 外层循环follow-upL262-271
```ts
// 内层退出agent 本来要停了
const followUpMessages = (await config.getFollowUpMessages?.()) || []; // L263
if (followUpMessages.length > 0) {
pendingMessages = followUpMessages; // L266 设为 pending内层会注入
continue; // L267 重启内层
}
break; // L271 真的没东西了,退出
}
await emit({ type: "agent_end", messages: newMessages }); // L274 最终结束
}
```
**follow-up 只在内层"本来要退出"时才检查**——这是它和 steering 的本质区别。steering 是打断(内层每次都 pollfollow-up 是续接(内层要停才问)。
### 停止条件总结
| # | 条件 | 触发位置 |
|---|---|---|
| ① | provider 返回 `error`/`aborted` | L196 |
| ② | `shouldStopAfterTurn` 返回 true | L247 |
| ③ | 没工具调用、没 steering、没 follow-up自然结束 | 内层退出 + 外层 break |
| ④ | 工具批全部 `terminate: true`(通过 `hasMoreToolCalls=false` | L216 + 内层退出 |
**截断length不是停止条件**——它只是让工具全部失败,循环继续(模型会看到错误,重发工具调用)。
---
## 三、`streamAssistantResponse` —— 边界转换L281-374⭐⭐
**这是 AgentMessage↔Message 唯一的翻译点**,也是循环和 provider 的接口。
### 三步转换L288-314
```ts
async function streamAssistantResponse(...): Promise<AssistantMessage> {
// 第①步可选的上下文变换AgentMessage → AgentMessage
let messages = context.messages;
if (config.transformContext) {
messages = await config.transformContext(messages, signal); // 剪枝/注入
}
// 第②步:转换成 LLM 消息AgentMessage → Message[]
const llmMessages = await config.convertToLlm(messages);
// 第③步:构建 LLM 上下文
const llmContext: Context = {
systemPrompt: context.systemPrompt,
messages: llmMessages,
tools: context.tools,
};
const streamFunction = streamFn || streamSimple; // L304 可注入自定义 streamFn
// 动态解析 API key处理过期 token
const resolvedApiKey =
(config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
// 调 provider
const response = await streamFunction(config.model, llmContext, { ...config, apiKey: resolvedApiKey, signal });
```
**为什么 `transformContext` 在 `convertToLlm` 之前?** 因为 `transformContext` 操作 `AgentMessage[]`(应用级,可剪枝自定义消息、注入外部上下文),而 `convertToLlm` 是格式转换AgentMessage → Message。顺序是"先处理应用语义,再转换协议格式"。
### 流式累加L316-373—— 流式消息是"活"的
```ts
let partialMessage: AssistantMessage | null = null;
let addedPartial = false; // 标记是否已 push 到 context.messages
for await (const event of response) {
switch (event.type) {
case "start": // L321-326
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":
// L337-346 所有 delta 事件统一处理
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": { // L348-361
const finalMessage = await response.result();
if (addedPartial) {
context.messages[last] = finalMessage; // 替换为最终版
} else {
context.messages.push(finalMessage); // 没 push 过就 push
}
if (!addedPartial) {
await emit({ type: "message_start", message: { ...finalMessage } }); // 补发 message_start
}
await emit({ type: "message_end", message: finalMessage });
return finalMessage;
}
}
}
// L365-373 流自然结束(没 done/error 事件)的兜底
const finalMessage = await response.result();
if (addedPartial) context.messages[last] = finalMessage;
else { context.messages.push(finalMessage); await emit({ type: "message_start", message: { ...finalMessage } }); }
await emit({ type: "message_end", message: finalMessage });
return finalMessage;
}
```
**核心设计:流式消息在流式过程中就躺在 transcript 末尾**
- `start` 事件 → push 空的 partial 消息
- 每个 delta 事件 → 用新 partial **原地替换**最后一个元素
- `done`/`error` → 用最终消息替换
这让 UI 能实时看到流式输出(通过 `message_update` 事件),而 transcript 始终保持"当前最新状态"。
**`addedPartial` 标志**L317处理一个边界情况如果 provider 没发 `start` 事件直接发 `done`(某些 provider 的行为),那 transcript 里还没有 partial需要补发 `message_start`
**L365-373 的兜底**如果流自然耗尽for-await 结束)但没收到 `done`/`error`,用 `response.result()` 取最终消息——保证总有最终消息入栈。这是健壮性设计。
**为什么 emit 时用 `{ ...partialMessage }` 浅拷贝?**L325, L343因为 partial 后续会被替换emit 出去的应该是那一刻的快照,避免消费者持有的是会被原地修改的引用。
---
## 四、工具执行L383-755
### 截断失败L383-408
```ts
async function failToolCallsFromTruncatedMessage(toolCalls, emit): Promise<ExecutedToolCallBatch> {
const messages: ToolResultMessage[] = [];
for (const toolCall of toolCalls) {
await emit({ type: "tool_execution_start", toolCallId: toolCall.id, toolName: toolCall.name, args: toolCall.arguments });
const finalized: FinalizedToolCallOutcome = {
toolCall,
result: createErrorToolResult(`...arguments may be truncated. Re-issue the tool call...`),
isError: true,
};
await emitToolExecutionEnd(finalized, emit);
const toolResultMessage = createToolResultMessage(finalized);
await emitToolResultMessage(toolResultMessage, emit);
messages.push(toolResultMessage);
}
return { messages, terminate: false }; // 不终止——让模型看到错误重发
}
```
**模拟完整的工具执行事件序列**start → end → message但结果永远是错误。`terminate: false` 让循环继续——模型会看到"参数可能截断"的错误,重新发工具调用。
### 模式选择L413-428
```ts
async function executeToolCalls(...): Promise<ExecutedToolCallBatch> {
const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
// 只要有一个工具声明了 sequential整个批次就退化为 sequential
const hasSequentialToolCall = toolCalls.some((tc) =>
currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential");
if (config.toolExecution === "sequential" || hasSequentialToolCall) {
return executeToolCallsSequential(...);
}
return executeToolCallsParallel(...);
}
```
**保守策略**:批内有一个 sequential 工具就全部顺序执行。因为 sequential 通常意味着有副作用(如写文件),并发可能冲突。
### 顺序执行L435-489
```ts
async function executeToolCallsSequential(...): Promise<ExecutedToolCallBatch> {
const finalizedCalls: FinalizedToolCallOutcome[] = [];
const messages: ToolResultMessage[] = [];
for (const toolCall of toolCalls) { // 一个一个来
await emit({ type: "tool_execution_start", ... });
const preparation = await prepareToolCall(...); // 验证 + before hook
let finalized: FinalizedToolCallOutcome;
if (preparation.kind === "immediate") { // 验证失败/blocked/未找到 → 直接出结果
finalized = { toolCall, result: preparation.result, isError: preparation.isError };
} else {
const executed = await executePreparedToolCall(...); // 执行
finalized = await finalizeExecutedToolCall(...); // after hook
}
await emitToolExecutionEnd(finalized, emit); // 立即发 end
const toolResultMessage = createToolResultMessage(finalized);
await emitToolResultMessage(toolResultMessage, emit); // 立即发 toolResult message
finalizedCalls.push(finalized);
messages.push(toolResultMessage);
if (signal?.aborted) break; // 中止则跳出
}
return { messages, terminate: shouldTerminateToolBatch(finalizedCalls) };
}
```
**顺序模式的特点start→execute→end→message 一个工具完整走完才下一个**。中止会 break已完成的工具结果保留。
### 并发执行L491-556—— 精妙之处
```ts
async function executeToolCallsParallel(...): Promise<ExecutedToolCallBatch> {
const finalizedCalls: FinalizedToolCallEntry[] = []; // 注意类型是 Entry可能是函数
// 第一阶段:顺序 prepare含 before hook
for (const toolCall of toolCalls) {
await emit({ type: "tool_execution_start", ... });
const preparation = await prepareToolCall(...);
if (preparation.kind === "immediate") { // immediate 直接 finalize + 发 end
const finalized = { toolCall, result: preparation.result, isError: preparation.isError };
await emitToolExecutionEnd(finalized, emit);
finalizedCalls.push(finalized); // 直接存结果
if (signal?.aborted) break;
continue;
}
// prepared → 存一个 thunk延迟执行
finalizedCalls.push(async () => {
const executed = await executePreparedToolCall(preparation, signal, emit);
const finalized = await finalizeExecutedToolCall(...);
await emitToolExecutionEnd(finalized, emit); // end 按完成顺序发
return finalized;
});
if (signal?.aborted) break;
}
// 第二阶段:并发执行所有 thunk
const orderedFinalizedCalls = await Promise.all(
finalizedCalls.map((entry) => (typeof entry === "function" ? entry() : Promise.resolve(entry))),
);
// 第三阶段toolResult message 按 assistant 源顺序发(不是完成顺序!)
const messages: ToolResultMessage[] = [];
for (const finalized of orderedFinalizedCalls) {
const toolResultMessage = createToolResultMessage(finalized);
await emitToolResultMessage(toolResultMessage, emit);
messages.push(toolResultMessage);
}
return { messages, terminate: shouldTerminateToolBatch(orderedFinalizedCalls) };
}
```
**并发模式的三个阶段**
1. **顺序 prepare**:所有工具的 `prepareToolCall`(含 before hook顺序跑。为什么顺序因为 before hook 可能 block且 prepare 可能修改共享状态。
2. **并发 execute**prepared 的工具存成 thunk`Promise.all` 并发执行。`tool_execution_end` 按**完成顺序**发(每个 thunk 完成时立即发)。
3. **顺序 emit message**`toolResult` 的 `message_start/end` 按**源顺序**发(`orderedFinalizedCalls` 保持原顺序)。
**`FinalizedToolCallEntry` 是联合类型**L582
```ts
type FinalizedToolCallEntry = FinalizedToolCallOutcome | (() => Promise<FinalizedToolCallOutcome>);
```
immediate 的存结果对象prepared 的存 thunk。第二阶段用 `typeof entry === "function"` 区分。
**为什么 end 按完成顺序、message 按源顺序?** 因为 `tool_execution_end` 是给 UI 看的(哪个工具先做完先显示),而 toolResult message 是给**下一轮 LLM**看的(要和 assistant 的 toolCall 顺序对应,否则模型会困惑)。
### `prepareToolCall` —— 验证 + before hookL602-666
```ts
async function prepareToolCall(...): Promise<PreparedToolCall | ImmediateToolCallOutcome> {
const tool = currentContext.tools?.find((t) => t.name === toolCall.name);
if (!tool) { // L610 工具未找到
return { kind: "immediate", result: createErrorToolResult(`Tool ${toolCall.name} not found`), isError: true };
}
try {
const preparedToolCall = prepareToolCallArguments(tool, toolCall); // L619 prepareArguments shim
const validatedArgs = validateToolArguments(tool, preparedToolCall); // L620 schema 验证
if (config.beforeToolCall) { // L621 before hook
const beforeResult = await config.beforeToolCall(
{ assistantMessage, toolCall, args: validatedArgs, context: currentContext }, signal);
if (signal?.aborted) return { kind: "immediate", result: createErrorToolResult("Operation aborted"), isError: true };
if (beforeResult?.block) { // L638 block → 错误结果
return { kind: "immediate", result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"), isError: true };
}
}
if (signal?.aborted) return { kind: "immediate", result: createErrorToolResult("Operation aborted"), isError: true };
return { kind: "prepared", toolCall, tool, args: validatedArgs }; // L653 通过 → 执行
} catch (error) { // L659 验证抛错 → 错误结果
return { kind: "immediate", result: createErrorToolResult(error.message), isError: true };
}
}
```
**三道关卡**:① 工具存在性 ② `prepareArguments`LLM 输出规整)+ `validateToolArguments`schema 验证)③ `beforeToolCall` hook权限/安全)。
**返回 `immediate` 的情况**工具未找到、参数验证失败、before hook block、已中止。这些都跳过执行直接产生错误结果——**错误被编码成结果,而非抛出**。
### `executePreparedToolCall` —— 执行 + 流式更新L668-709
```ts
async function executePreparedToolCall(prepared, signal, emit): Promise<ExecutedToolCallOutcome> {
const updateEvents: Promise<void>[] = [];
let acceptingUpdates = true; // 更新开关
try {
const result = await prepared.tool.execute(
prepared.toolCall.id,
prepared.args as never,
signal,
(partialResult) => { // onUpdate 回调
if (!acceptingUpdates) return; // settle 后的调用忽略
updateEvents.push(Promise.resolve(emit({ type: "tool_execution_update", ... partialResult })));
},
);
acceptingUpdates = false; // 执行完成,关闭更新
await Promise.all(updateEvents); // 等待所有 update 事件 flush
return { result, isError: false };
} catch (error) {
acceptingUpdates = false;
await Promise.all(updateEvents); // 错误时也 flush 更新
return { result: createErrorToolResult(error.message), isError: true };
} finally {
acceptingUpdates = false; // 确保 finally 也关闭
}
}
```
**`onUpdate` 回调的作用域**:只在 `execute()` promise 未 settle 时有效。`acceptingUpdates` 三处置 false成功/失败/finally保证 settle 后的调用被忽略。
**`updateEvents` 收集机制**update 回调里不直接 await emit回调是同步的而是把 emit 的 promise 推入数组execute 完成后统一 `Promise.all` flush。这避免了回调内的异步竞态。
**错误处理**L699-705工具 execute 抛错 → `createErrorToolResult``isError: true`。**错误编码成结果,绝不抛出**。
### `finalizeExecutedToolCall` —— after hookL711-755
```ts
async function finalizeExecutedToolCall(...): Promise<FinalizedToolCallOutcome> {
let result = executed.result;
let isError = executed.isError;
if (config.afterToolCall) {
try {
const afterResult = await config.afterToolCall(
{ assistantMessage, toolCall: prepared.toolCall, args: prepared.args, result, isError, context: currentContext },
signal);
if (afterResult) {
// 字段级覆盖,无深度合并
result = {
...result,
content: afterResult.content ?? result.content, // 给了就替换
details: afterResult.details ?? result.details,
terminate: afterResult.terminate ?? result.terminate,
};
isError = afterResult.isError ?? isError;
}
} catch (error) { // after hook 抛错 → 错误结果
result = createErrorToolResult(error.message);
isError = true;
}
}
return { toolCall: prepared.toolCall, result, isError };
}
```
**after hook 的字段级覆盖**L736-742`content`/`details`/`terminate` 用 `??`(给了就替换,没给保持原值)。`isError` 同理。**无深度合并**——这是有意的,避免意外的部分覆盖导致状态不一致。
用途:脱敏(改写 content、强制标记错误`isError: true`)、审计日志(读取 details
---
## 五、辅助函数L757-792
```ts
// L757-762 错误结果工厂
function createErrorToolResult(message: string): AgentToolResult<any> {
return { content: [{ type: "text", text: message }], details: {} };
}
// L774-787 构造 ToolResultMessage
function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage {
return {
role: "toolResult",
toolCallId: finalized.toolCall.id,
toolName: finalized.toolCall.name,
content: finalized.result.content ?? [], // L781 null 归一化JS 扩展工具可能无 content
details: finalized.result.details,
...(finalized.result.addedToolNames?.length ? { addedToolNames: finalized.result.addedToolNames } : {}),
isError: finalized.isError,
timestamp: Date.now(),
};
}
```
**L778-781 的 null 归一化**:注释说"untyped tools (JS extensions) can return results without content"。因为 JS 扩展工具不走 Rust 的强类型,可能返回 `content: null`。这里 `?? []` 保证 null 永不进入 session 历史或 provider payload——防御性编程。
**`addedToolNames` 条件展开**L783只在非空时加入字段避免序列化出空的 `addedToolNames: []`
---
## 六、设计模式总结
读完 792 行,提炼出 agent-loop.ts 的核心设计模式:
### 1. 错误编码而非抛出(贯穿全文)
- `StreamFn` 契约:不 throw错误进 stream
- `prepareToolCall`:验证失败返回 `immediate` 错误结果
- `executePreparedToolCall`:工具抛错 → `createErrorToolResult`
- `finalizeExecutedToolCall`after hook 抛错 → 错误结果
**所有错误都变成 `ToolResultMessage(isError: true)`**,循环永远不会因为工具错误而中断事件序列。
### 2. 生产者-消费者解耦L34-49
`agentLoop` 立即返回 EventStream后台 `void` promise 推事件。消费者 `for await` 拉事件,生产者跑循环。
### 3. 流式消息"活性"L321-361
partial 消息在流式过程中就躺在 transcript 末尾delta 原地替换。UI 实时可见transcript 始终最新。
### 4. 双层循环分离语义L170-272
- 内层steering打断+ 工具调用
- 外层follow-up续接
两种"中途加消息"的语义清晰区分。
### 5. 边界唯一转换L288-295
`convertToLlm` 是 AgentMessage→Message 的唯一桥梁。`transformContext`AgentMessage→AgentMessage在它之前。
### 6. 顺序 vs 并发的精巧设计L435-556
- sequential完整流水线一个一个来
- parallelprepare 顺序、execute 并发、message 按源序
### 7. 字段级覆盖无深度合并L736-742
after hook 的覆盖是"给了就整体替换,没给就保持",避免部分覆盖导致状态不一致。
### 8. 防御性归一化L781
外部来源JS 扩展)的 `content: null``?? []` 归一化,保证内部不变量。
---
## 七、纯 std 实现启示
读完循环逻辑,对 focus 实现的关键启示:
1. **循环本身是纯的状态机**——`runLoop` 只是 `while` + 条件判断 + 函数调用,零 I/O。纯 std 完全可实现。
2. **`StreamFn` 可以是 trait**
```rust
trait StreamFn: Send {
fn stream(&self, model: &Model, ctx: &Context, opts: &StreamOptions)
-> Pin<Box<dyn Stream<Item = StreamEvent> + Send>>;
}
```
或者更简单——用同步迭代器/channel。核心循环不关心 stream 怎么来。
3. **EventStream 可用 `mpsc::Receiver` 替代**
```rust
let (tx, rx) = mpsc::channel();
// 后台线程循环跑tx.send(event)
// 主线程rx.iter() 消费
```
Rust 的 std::sync::mpsc 完全够用。或者用 crossbeam但那不是 std
4. **并发工具执行可用 `std::thread` + `JoinHandle`**:顺序 preparespawn 线程并发 executejoin 收集结果。
5. **AgentMessage→Message 转换是纯函数**——不依赖任何外部库。
**结论**agent-loop.ts 的逻辑可以几乎 1:1 移植到纯 std Rust唯一的妥协是 async可以用同步阻塞 + 线程替代)和 JSON手写极简解析器或认栽用 serde
---
至此Pi 的核心两层(类型 + 循环)已完整解读。配合 [00-pi-architecture-overview.md](./00-pi-architecture-overview.md) 的全景,你已具备从零实现一个 agent 框架所需的全部知识。