Skip to content

5.3 一次 Tool Call 的完整循环 ​

本页分析版本earendil-works/pi@16787ad2026-09-21

本章解决什么问题:5.2 走完了「问一句、答一句」的路径。现在把工具加进来——模型说「我要读文件」之后,到底是谁去读的文件、读到的东西怎么回到模型面前、循环什么时候才停。 前置知识:3.4 工具调用(Tool Calling)、3.5 Agent 与 Agent Loop、5.2 一次普通请求的完整路径。 学习目标:读完后你能 ① 说清「模型从不执行任何东西,执行者永远是 harness」这句话在源码里对应哪几行;② 完整背出 toolCall 内容块 → 检测 → 分派 → 校验 → 执行 → 回填 → 再请求 这条链路的函数与行号;③ 逐段讲出 read 工具从参数到截断输出的全过程;④ 说出真实实现比教科书伪代码多做了哪几类工程工作。

建立直觉:模型只会「说」,不会「做」 ​

先澄清一个几乎人人都会踩的误解。

⚠️ 常见误解以为模型自己读了文件、自己跑了命令
模型是一个「给定文本、预测下一段文本」的函数。它没有文件句柄,没有子进程,没有网络出口。它唯一能做的事,是在输出里生成一个结构化的片段,意思是「请你替我调用名为 read、参数为 path=a.txt 的那个工具」。真正打开文件的是运行在你机器上的 Pi 进程。这个「替模型跑腿」的角色,就是 Agent Harness(Agent 运行框架)。

所以一次工具调用(Tool Calling)在物理上被拆成了两次模型请求:

  1. 第一次请求:harness 把「系统提示词 + 对话历史 + 工具声明清单」发给模型;模型回一条 assistant 消息,其中含有一个或多个 Tool Call(一次工具调用请求)。
  2. harness 执行工具,得到工具结果(Tool Result),把它当成一条新消息追加到上下文(Context)里。
  3. 第二次请求:harness 带着「原历史 + assistant 消息 + 工具结果」再问一次;模型这时才能基于文件内容说话。

如果模型第二次又要了一个工具,就重复 2–3。什么时候停?模型这一轮不再请求任何工具的时候。这正是 Pi 内层循环的退出条件。

最小示例:三十几行的工具回环 ​

下面这个脚本不依赖任何库、不需要 API Key,用一个「假模型」把上面三步演一遍。新建 mini-tool-loop.mjs:

js
// mini-tool-loop.mjs —— 用假模型演示「谁在执行工具」
const lines = ["第一行", "第二行", "第三行", "第四行"];
const tools = {
	read: async ({ limit = 2 }) => {
		const shown = lines.slice(0, limit).join("\n");
		return limit < lines.length ? `${shown}\n[还有 ${lines.length - limit} 行,用 offset=${limit + 1} 继续]` : shown;
	},
};

let round = 0; // 假模型:第一轮请求工具,第二轮根据工具结果收尾
async function fakeModel(messages) {
	round++;
	if (round === 1) {
		return { role: "assistant", content: [{ type: "toolCall", id: "c1", name: "read", arguments: { path: "a.txt", limit: 2 } }] };
	}
	const last = messages[messages.length - 1];
	return { role: "assistant", content: [{ type: "text", text: `文件开头是:${last.content.split("\n")[0]}` }] };
}

const messages = [{ role: "user", content: "看看 a.txt 开头写了什么" }];
let hasMoreToolCalls = true;
while (hasMoreToolCalls) {
	const message = await fakeModel(messages);
	messages.push(message);
	const toolCalls = message.content.filter((c) => c.type === "toolCall");
	hasMoreToolCalls = toolCalls.length > 0;
	for (const call of toolCalls) {
		console.log(`[harness] 执行工具 ${call.name}`, call.arguments);
		const content = await tools[call.name](call.arguments);
		messages.push({ role: "toolResult", toolCallId: call.id, content });
	}
}
console.log("[模型最终回答]", messages[messages.length - 1].content[0].text);

运行 node mini-tool-loop.mjs,本机(Node v26.5.0)真实输出为:

text
[harness] 执行工具 read { path: 'a.txt', limit: 2 }
[模型最终回答] 文件开头是:第一行

请注意三件事,它们和 Pi 的真实实现一一对应:

  • hasMoreToolCalls 这个变量名不是我编的,Pi 的循环里就叫这个名字。
  • 工具结果是一条新消息(role: "toolResult"),带着 toolCallId 与原请求配对——模型靠这个 id 知道哪条结果对应哪次请求。
  • 工具输出里那句「还有 N 行,用 offset 继续」不是装饰。上下文窗口有限,工具必须把「我截断了、你可以这样继续」讲给模型听。Pi 在真实的 read 工具里做的就是这件事。

回到 Pi 源码 ​

第一步:模型返回了什么 ​

流式响应结束后,streamAssistantResponse 会返回一条完整的 AssistantMessage(5.2 讲过这条路径,packages/agent/src/agent-loop.ts:241)。它的 content 是一个数组,元素可能是文本、思考内容,也可能是 Tool Call(源码事实):

earendil-works/pi@16787ad第 386–394 行在 GitHub 查看 ↗
模型「请求调用工具」在数据上就是 assistant 消息 content 数组里的一个块:id 用于配对结果,name 指向工具名,arguments 是已经解析好的参数对象。

arguments 的类型是 JsonObject(定义在 packages/ai/src/types.ts:421-422)——它只保证「这是一个能写成 JSON 的对象」,并不知道里面该有哪些字段、字段该是什么类型。模型生成的是 JSON 文本,流式解析出来的对象在这一刻还没有对照任何工具的 schema 校验过,它是不可信输入。这一点决定了后面一整段校验代码的存在理由。

第二步:循环如何检测工具调用 ​

回到 runLoop 的内层循环。5.2 里我们只看了「拿到消息就结束」,现在看它后面那一段(源码事实):

ts
// packages/agent/src/agent-loop.ts:258-294(有省略)
const toolCalls = message.content.filter((c) => c.type === "toolCall");
const toolResults: ToolResultMessage[] = [];
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
	const executedToolBatch =
		message.stopReason === "length"
			? await failToolCallsFromTruncatedMessage(toolCalls, emit)
			: await executeToolCalls(currentContext, message, config, signal, emit);
	toolResults.push(...executedToolBatch.messages);
	hasMoreToolCalls = !executedToolBatch.terminate;

	for (const result of toolResults) {
		currentContext.messages.push(result);   // ← 工具结果回填上下文
		newMessages.push(result);
	}
}
lastCompletedTurn = { message, toolResults, context: currentContext, newMessages };
const decision = await config.finishTurn?.(lastCompletedTurn, signal);
await emit({ type: "turn_end", message, toolResults });
// …(省略:decision 为 end 时直接 agent_end;记下 continue 决定;轮询 steering 队列)
earendil-works/pi@16787ad第 258–277 行在 GitHub 查看 ↗
检测、分派、回填三件事挤在同一段:先把 toolCall 块过滤出来;没有就让 hasMoreToolCalls 保持 false,内层 while 下一轮判断不成立,循环自然收尾。

内层循环的条件是 while (hasMoreToolCalls || pendingMessages.length > 0)(packages/agent/src/agent-loop.ts:182)。所以「模型这一轮只回文本」= toolCalls.length === 0 = hasMoreToolCalls 留在 false = 循环退出。纯文本收尾在源码里就是这么朴素的一件事。

hasMoreToolCalls = !executedToolBatch.terminate 这一行还藏着第二个出口:工具可以在结果里设 terminate: true 请求提前停机。但判定很严格——只有整批工具结果都设了才算数(源码事实,packages/agent/src/agent-loop.ts:685-687 的 shouldTerminateToolBatch)。被 beforeToolCall 钩子拦下、根本没执行的调用也能投这一票:钩子返回 { block: true, terminate: true } 时,那条错误结果同样带上 terminate(agent-loop.ts:739-749,字段说明见 packages/agent/src/types.ts:66-74)。

工具结果回填之后、turn_end 发出之前,循环会先调一次可选的 finishTurn 钩子,把这一轮的 assistant 消息和工具结果交给它;它返回 { action: "end" } 就直接结束整次运行,返回 { action: "continue" } 则保证至少再发一次请求(agent-loop.ts:279-297,契约写在 types.ts:252-260)。和它配套的还有两个钩子:prepareNextTurn 只在确定要开下一轮时运行,可以替换上下文、模型或追加消息(agent-loop.ts:184-207);prepareRequest 则在每一次请求 Provider 之前运行,包括第一次(agent-loop.ts:218-238)。coding-agent 的 AgentSession 在这三处都挂了东西:prepareRequest 里把请求历史换成会话管理器算出来的那一份(packages/coding-agent/src/core/agent-session.ts:608-633),finishTurn 里把扩展的 turn_end 事件派发出去(:675-685),prepareNextTurn 里做下一轮之前的压缩检查、刷新系统提示词与工具清单(:687-724)。本章的工具回环不依赖它们,知道它们挂在哪儿就够了;前两项在 5.5 Session 的创建、保存与恢复 还会出现。

截断保护:不是所有 toolCall 都值得执行 ​

stopReason === "length" 那个三元分支值得单独说。它的意思是:模型的输出被 token 上限截断了。

packages/agent/src/agent-loop.ts · failToolCallsFromTruncatedMessage
earendil-works/pi@16787ad第 475–500 行在 GitHub 查看 ↗
被 length 截断的消息里,每个 tool call 都不执行,一律返回一条错误结果,并在文案里让模型「用完整参数重发」。

为什么不执行?函数上方的注释给了理由(源码事实,agent-loop.ts:468-474):流式的工具参数是用「尽力而为的 JSON 抢救解析器」拼出来的,一条被截断的消息完全可能产出能通过 schema 校验、但语义上残缺的参数。想象 bash 工具收到半截命令,或者 write 工具收到半截文件内容——执行它们的代价远大于让模型重发一次。

这条分支有专门的测试固化:packages/agent/test/agent-loop.test.ts 中的 "should not execute tool calls from a length-truncated assistant message"(第 408 行)。

第三步:串行还是并行 ​

earendil-works/pi@16787ad第 505–520 行在 GitHub 查看 ↗
分派器:默认并行;但只要配置要求串行,或者这一批里任意一个工具自己声明了 executionMode === "sequential",整批都退化为串行。

「一个串行工具拖累整批」是刻意写成这样的:判断条件用的是 toolCalls.some(...),而不是逐个工具分组。至于为什么这样设计,源码与注释都没有说明;据此推断(尚未在源码中直接证实),是为了避免「模型同时请求 edit 改文件和 read 读同一个文件」这类竞态——与其让每个工具自己加锁,不如让声明了 sequential 的工具把整批拉回顺序执行。一种看法是这个策略过于保守:只要有一个慢工具声明串行,整批的并发收益就没了;代价换来的是「不用推理任意两个工具之间是否互相干扰」这份确定性。

两条分支分别是 executeToolCallsSequential(packages/agent/src/agent-loop.ts:527-581)与 executeToolCallsParallel(packages/agent/src/agent-loop.ts:583-657)。并行版有一个容易看漏的细节:它先顺序地对每个 tool call 做准备(查找 + 校验 + 前置钩子),把真正的执行包成闭包塞进数组,最后用 Promise.all 一起跑(agent-loop.ts:643-645)。因此并发的只是 execute 本身,准备阶段仍是有序的。

第四步:准备一次调用——查找、垫片、校验、钩子 ​

earendil-works/pi@16787ad第 703–771 行在 GitHub 查看 ↗
一次 tool call 在真正执行前要过四关:按名字查工具、prepareArguments 兼容垫片、schema 校验、beforeToolCall 钩子。任何一关失败都转成 isError 的工具结果,而不是抛给上层。
ts
// packages/agent/src/agent-loop.ts:710-721(有省略)
const tool = currentContext.tools?.find((t) => t.name === toolCall.name);
if (!tool) {
	return { kind: "immediate", result: createErrorToolResult(`Tool ${toolCall.name} not found`), isError: true };
}
try {
	const preparedToolCall = prepareToolCallArguments(tool, toolCall);
	const validatedArgs = validateToolArguments(tool, preparedToolCall);
	// …(省略:beforeToolCall 钩子可 block;abort 检查;722-757 行)
} catch (error) {
	// …(省略:764-770 行,把异常转成错误工具结果)
}

这里最值得体会的是错误的去向:工具名写错、参数类型不对、钩子拦截——全都不会让循环崩溃,而是变成一条内容为错误文本的工具结果,回填给模型。模型读到的是一段形如 Validation failed for tool "read": 加逐条错误路径的文本(拼装模板见 packages/ai/src/utils/validation.ts:341-347),据此通常能自己改正重发。把错误当作对话内容,是 Agent Harness 相对普通 RPC(远程过程调用)框架最特别的错误处理观。

校验本身在 pi-ai 包里:

packages/ai/src/utils/validation.ts · validateToolArguments
earendil-works/pi@16787ad第 317–350 行在 GitHub 查看 ↗
先 structuredClone 复制一份,把可选字段上模型填的 null 清掉,再 Value.Convert 做类型强转(把 "3" 变成 3 这类),然后用编译过的 typebox validator 检查;失败时把每条错误的路径和原始参数一起拼进异常消息。

注意最后那句 Received arguments: 加原始 JSON——错误消息是写给模型看的,所以要把它当时发了什么原样还给它。

第五步:真正执行 ​

packages/agent/src/agent-loop.ts · executePreparedToolCall
earendil-works/pi@16787ad第 773–814 行在 GitHub 查看 ↗
整个 agent-loop.ts 里唯一出现 tool.execute 调用的地方。它传入 toolCallId、校验后的参数、AbortSignal 和一个 onUpdate 回调;工具抛出的任何异常都在此被接住并转成错误结果。
ts
// packages/agent/src/agent-loop.ts:782-803(有省略)
const result = await prepared.tool.execute(
	prepared.toolCall.id,
	prepared.args as never,
	signal,
	(partialResult) => {
		if (!acceptingUpdates) return;
		updateEvents.push(Promise.resolve(emit({ type: "tool_execution_update", /* …(省略字段) */ partialResult })));
	},
);
acceptingUpdates = false;
await Promise.all(updateEvents);
return { result, isError: false };

acceptingUpdates 这个开关是防御性的:工具 Promise 结算之后再调 onUpdate(比如某个没清理干净的定时器)会被静默忽略,避免结算后的事件污染界面。这条语义在 AgentToolUpdateCallback 的注释里写明了(packages/agent/src/types.ts:434-440)。

以 read 工具为例:从参数到截断输出 ​

Pi 的命令行界面(CLI)内置八个工具,pi --help 的帮助文本里列出了它们(源码事实,这段文字就写在 packages/coding-agent/src/cli/args.ts:438-447):

text
Built-in Tool Names:
  read       - Read file contents
  bash       - Execute bash commands
  powershell - Execute PowerShell commands on Windows
  edit       - Edit files with find/replace
  write      - Write files (creates/overwrites)
  grep       - Search file contents (read-only, off by default)
  find       - Find files by glob pattern (read-only, off by default)
  ls         - List directory contents (read-only, off by default)

启动时默认激活的只有四个:源码里那份默认清单是 ["read", "bash", "edit", "write"](源码事实,packages/coding-agent/src/core/sdk.ts:258,AgentSession 里同样的兜底在 packages/coding-agent/src/core/agent-session.ts:3281-3283)。grep / find / ls 标了「off by default」;powershell 是给 Windows 用户的可选项,同样不在默认清单里。想换一套启动工具,可以在设置里写 defaultTools(sdk.ts:259-265;官方文档 packages/coding-agent/docs/settings.md:287 与 docs/windows.md:19 给了 Windows 下用 powershell 替换 bash 的写法)。八个工具都在注册表里,但只有被激活的才会进 context.tools,进而被声明给模型。

我们挑最简单、无副作用的 read 走一遍。它的定义是一个 ToolDefinition:

earendil-works/pi@16787ad第 66–108 行在 GitHub 查看 ↗
read 的声明部分:description 里直接写明「输出会截断到 2000 行或 50KB,用 offset/limit 分页」——这段文字是发给模型的说明书。execute 开头先解析路径、检查可读、判断是不是图片。

execute 的文本分支(packages/coding-agent/src/core/tools/read.ts:132-183)大致是这样:

ts
// packages/coding-agent/src/core/tools/read.ts:134-173(有省略)
const buffer = await ops.readFile(absolutePath);
const textContent = buffer.toString("utf-8");
const allLines = textContent.split("\n");
const startLine = offset ? Math.max(0, offset - 1) : 0;
const startLineDisplay = startLine + 1;
if (startLine >= allLines.length) {
	throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
}
// …(省略:145-154 行,用户显式给了 limit 时先按 limit 切片)
const truncation = truncateHead(selectedContent);
// …(省略:158-162 行,首行本身就超字节上限时,提示改用 bash + sed 兜底)
if (truncation.truncated) {
	const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
	const nextOffset = endLineDisplay + 1;
	outputText = truncation.content;
	if (truncation.truncatedBy === "lines") {
		outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
	} // …(省略:170-172 行 else 分支:按字节上限截断时的另一种措辞)
	details = { truncation };
}

几个设计点:

  • 参数已经是可信的。path、offset、limit 到这里时已过 typebox 校验,工具内部不再做类型判断,只做业务判断(比如 offset 越界)。
  • 失败就抛。AgentTool.execute 的注释明确要求「失败时抛异常,而不是把错误编码进 content」(源码事实,packages/agent/src/types.ts:451);统一转错误结果的活由循环干。
  • 截断必须自带续读说明。这是全书反复出现的主题:上下文窗口是稀缺资源。
earendil-works/pi@16787ad第 78–119 行在 GitHub 查看 ↗
双上限先到先截:2000 行或 50KB。truncateHead 永不返回半行;若第一行本身就超过字节上限,返回空内容并置 firstLineExceedsLimit,让 read 去提示 bash 兜底方案。

read 用 truncateHead(要开头),bash 用 truncateTail(要结尾的报错信息),grep 用 truncateLine(防单行爆炸)——同一个文件里三种策略,对应三种「读者最想看哪一段」的判断(源码事实,packages/coding-agent/src/core/tools/truncate.ts:78、:168、:268)。

🌱 初学者提示ToolDefinition 与 AgentTool 的一层包装
read 定义出来是 ToolDefinition(coding-agent 层,比 agent 层多了 promptSnippet 与终端界面渲染函数,execute 有第 5 个参数 ctx);agent 包的循环只认识 4 个参数的 AgentTool。中间的适配器是 wrapToolDefinition(packages/coding-agent/src/core/tools/tool-definition-wrapper.ts:5-20),它把第 5 个参数用一个工厂函数补上。工具类型的三层结构在 6.4 工具系统细讲。

第六步:收尾、包装、回填 ​

执行完还有两步。finalizeExecutedToolCall(packages/agent/src/agent-loop.ts:816-861)调用 afterToolCall 钩子,允许扩展(Extension)按字段覆盖 content / details / usage / terminate / isError。然后:

packages/agent/src/agent-loop.ts · createToolResultMessage
earendil-works/pi@16787ad第 880–898 行在 GitHub 查看 ↗
把最终结果包成 role 为 "toolResult" 的消息:带 toolCallId 与 toolName 用于配对,content 为空时归一成空数组,isError 记录是否失败;随后成对发出 message_start / message_end 事件。

这条 ToolResultMessage 随即被 push 进 currentContext.messages(前面那段 agent-loop.ts:273-276),于是它成为下一次请求上下文的一部分。coding-agent 的 convertToLlm 对 toolResult 角色原样透传给 Provider(模型服务提供方)(源码事实,packages/coding-agent/src/core/messages.ts:187-188);同时 AgentSession 在 message_end 事件里把它写进会话(Session)文件(packages/coding-agent/src/core/agent-session.ts:921-943)。

第七步:内层循环再来一遍 ​

hasMoreToolCalls 现在是 true,内层 while 条件成立,于是又走一次 streamAssistantResponse——这一次上下文里多了 assistant 消息和工具结果。模型看到文件内容,可能直接回答(纯文本,循环结束),也可能再要一个工具(继续绕圈)。

至于工具声明是怎么发给模型的:context.tools 只是「运行时能执行哪些工具」,模型能看到哪些工具,由对话记录里的 system 消息说了算。每次往上下文里追加消息之前,循环都会调 declareToolChanges,把 context.tools 和记录里已经声明过的工具做一次比对;有差异,就生成(或改写)一条带 toolsAdded / toolsRemoved 字段的 system 消息,和其他消息一样发出 message_start / message_end 并追加进上下文(源码事实,packages/agent/src/agent-loop.ts:322-362,调用点在 :109 与 :210)。最开头那条 system 消息是 Agent 创建时就准备好的:初始状态里的系统提示词和工具定义会被折叠成记录的第一条消息(packages/agent/src/agent.ts:77-86)。之后只要工具清单没变,declareToolChanges 就原样放行,不会多出 system 消息;对话中途激活或停用了工具,记录里才会多一条只写增减的 system 消息。streamAssistantResponse 自己不再单独传工具,只把转换后的消息交给 normalizeContext 打包(agent-loop.ts:393-396)。

到了 Provider 这一侧,Anthropic 适配器用 getCurrentTools 把记录里所有 system 消息的增减按顺序重放一遍,得到当前工具清单(packages/ai/src/api/anthropic-messages.ts:517-518;重放逻辑见 packages/ai/src/utils/transcript.ts:57-66),再把每个工具的 typebox parameters 序列化成 input_schema 下发(anthropic-messages.ts:1483-1488)。同一份 schema 有三个用途:发给模型当声明、运行时当校验规则、编译期当 TypeScript 类型。

图解 ​

图 5.3-1 把 5.2 的时序图扩展出工具回环
读法:从上往下是时间。和 5.2 相比多出来的是中间那一段——L 与 T 之间的往返,以及第二次发往 P 的请求。请重点看两处 Note:第一处对应 agent-loop.ts:258 与 :271,第二处对应 :880 与 :273-276。整张图里 P 从头到尾只做了两件事:收文本、吐文本。

图 5.3-2 单次 tool call 的生命周期:四个失败出口都通向同一个终点
读法:从上往下是正常路径 A→C→D→F→G→H→I→J,每个方框都标了对应的源码行。请重点看汇聚到 E 的那四支箭头——查不到工具、校验失败、被钩子拦截、执行抛异常,最后都变成同一种东西:一条 isError 的工具结果消息,照样走 I 和 J 回填进上下文。没有任何一条路径会让循环崩溃。这正是「把错误当对话内容」的设计在图上的样子。

图 5.3-3 并行执行时,事件顺序与消息顺序被刻意分开
关注 E 之后的分叉:界面事件按「谁先跑完谁先播报」发出,而写进消息历史的工具结果严格保持模型请求时的顺序。前者服务于终端界面的实时感,后者保证上下文可复现。这条行为由测试 "should emit tool_execution_end in completion order but persist tool results in source order" 固化(packages/agent/test/agent-loop.test.ts:620)。

真实实现比伪代码多了什么 ​

概念层面的工具循环(3.4 与 3.5 讨论的那一层,本章开头的最小示例就是它的样子)在骨架上是对的:过滤出 toolCall、逐个执行、结果塞回历史、没有 toolCall 就停。差距全在工程细节上:

伪代码里没有的Pi 里对应的实现为什么必须有
参数校验validateToolArguments(validation.ts:317-350)+ prepareArguments 垫片(agent-loop.ts:689-701)模型输出是不可信输入;类型错了要能让模型自己修
输出截断truncateHead / truncateTail / truncateLine(truncate.ts:78、:168、:268)一个 10MB 的文件能一次撑爆上下文窗口
并发与顺序executeToolCalls 分派(agent-loop.ts:505-520)、并行版的顺序保持(:643-651)多工具要快,但写文件类工具不能乱序
错误包装prepareToolCall 的 try/catch(:764-770)、executePreparedToolCall 的 catch(:804-810)一次工具失败不该终止整个会话
截断消息保护failToolCallsFromTruncatedMessage(:475-500)半截参数比没有参数更危险
取消贯穿全链的 AbortSignal(:732-738、:751-757、:617-625、:572-574)用户按下 Esc 时,正在跑的 bash 也要停
钩子beforeToolCall / afterToolCall(:722-750、:816-861)扩展要能拦截危险命令、改写结果

取消这条线索单独在 5.6 取消与错误处理 展开;钩子与扩展的关系见 7.1 Extension 系统。

实践任务 ​

🛠 实践任务用测试反推工具循环的真实行为

目标:不发一次真实模型请求,验证本章讲的三条行为:工具结果会回填、length 截断不执行工具、整批 terminate 才提前停。

前提:不需要任何 API Key。这些测试用的是假的 StreamFn,不联网。

步骤 1:在 Pi 仓库根目录运行整个文件的测试。

npm test --workspace=@earendil-works/pi-agent-core -- test/agent-loop.test.ts

预期现象:Test Files 1 passed (1)、Tests 35 passed (35),耗时不到一秒(本机真实运行结果)。

步骤 2:只跑与工具相关的用例,并打开逐条列表。

npm test --workspace=@earendil-works/pi-agent-core -- test/agent-loop.test.ts -t "tool" --reporter=verbose

预期现象:14 个用例通过、21 个被跳过;通过的列表里能看到 should handle tool calls and results、should not execute tool calls from a length-truncated assistant message、should stop after a tool batch when every tool result sets terminate=true(本机真实运行结果)。

步骤 3:用编辑器打开 packages/agent/test/agent-loop.test.ts,跳到第 312 行的 "should handle tool calls and results",找出三样东西:假模型第一轮返回的 toolCall 块长什么样、测试里的 execute 往数组里记了什么、断言检查了哪几个事件。

如何判断成功:你能指着测试代码说出「这一行就是 agent-loop.ts:258 过滤出来的东西」,并解释为什么假模型必须在第二轮返回纯文本,否则测试会一直循环下去。

常见错误:① 漏掉 --,参数会被 npm 自己吃掉而不是传给 vitest;② 在 packages/agent 目录里跑 --workspace 会报找不到工作区,请回到仓库根目录;③ -t 是按用例名过滤,被跳过的用例显示为 ↓,那是正常的,不是失败。

对应源码位置:packages/agent/src/agent-loop.ts:258-277(检测与回填)、:475-500(截断保护)、:685-687(terminate 判定)。想看工具自己的行为,另一个文件是 packages/coding-agent/test/tools.test.ts,其中 "should truncate files exceeding line limit"(第 100 行)正是本章 read 截断那一段的测试。

本章小结 ​

  • 模型不执行任何东西。它只在 assistant 消息的 content 里放一个 toolCall 块(packages/ai/src/types.ts:386-394);执行者永远是 harness,唯一的 tool.execute 调用点在 packages/agent/src/agent-loop.ts:782。
  • 完整链路:streamAssistantResponse(:241)→ 过滤 toolCall(:258)→ executeToolCalls 分派(:505-520)→ prepareToolCall 查找与校验(:703-771)→ executePreparedToolCall 执行(:773-814)→ finalizeExecutedToolCall 钩子(:816-861)→ createToolResultMessage(:880-893)→ 回填 currentContext(:273-276)→ finishTurn 与 turn_end(:279-291)→ 内层 while 再转一圈(:182)。
  • 循环的终止条件在本章只用到一个:这一轮没有 toolCall,hasMoreToolCalls 保持 false。另外三个(error/aborted、整批 terminate、finishTurn 返回 { action: "end" })在 5.6 与 6.3 展开。
  • 所有失败都变成一条 isError 的工具结果回给模型,而不是异常上抛;错误消息是写给模型看的。
  • read 工具的价值不在读文件,在于「截断之后告诉模型怎么继续」:truncateHead + Use offset=N to continue.。
  • 关键术语:Tool Call(一次工具调用请求)、工具结果(Tool Result)、内层循环、截断(truncation)、执行模式(executionMode)、terminate。
  • 关键源码索引:packages/agent/src/agent-loop.ts:258-294 / 322-362 / 475-500 / 505-520 / 703-771 / 773-814 / 880-898;packages/ai/src/utils/validation.ts:317-350;packages/ai/src/types.ts:386-394;packages/coding-agent/src/core/tools/read.ts:66-199;packages/coding-agent/src/core/tools/truncate.ts:11-13 / 78-160;测试 packages/agent/test/agent-loop.test.ts、packages/coding-agent/test/tools.test.ts。
  • 自测问题:① 模型一次返回了 3 个 toolCall,其中一个工具声明了 executionMode: "sequential",实际会怎么执行?为什么?② 工具 execute 里 throw new Error("文件不存在"),最终模型会看到什么?③ 为什么 stopReason === "length" 时不能执行工具,哪怕参数通过了校验?④ 并行执行时,tool_execution_end 事件顺序和写进历史的工具结果顺序为什么不一样?
  • 下一章:5.4 流式事件如何传播到界面——本章一路 emit 出去的 tool_execution_start/update/end 到底被谁接住、怎么变成终端里滚动的那几行。
  • 尚未展开:工具的注册与激活流程(哪些工具进了 context.tools)、扩展自定义工具、beforeToolCall 的权限模型,分别在 6.4 工具系统 与 7.3 自定义工具与斜杠命令;把这套循环自己写一遍见 8.3 Agent Loop 与工具执行。
✅ 自测问题参考答案先自己回答,再点开对照
  1. 整批三个都串行执行。分派器(agent-loop.ts:505-520)的判断是 toolCalls.some(t => …executionMode === "sequential")——只要这一批里任意一个工具声明了串行,整批就退化为 executeToolCallsSequential,而不是把串行的那个单独拎出来。这样设计是为了不必推理「任意两个工具之间会不会互相干扰」(比如模型同时请求 edit 改文件和 read 读同一个文件的竞态);代价是一个慢的串行工具会让整批的并发收益归零。
  2. 模型会看到一条 isError: true 的工具结果消息,内容就是那句「文件不存在」的错误文本——不是异常、不是崩溃。executePreparedToolCall 的 catch(agent-loop.ts:804-810)把工具抛出的任何异常接住,转成错误结果,照样经 createToolResultMessage 包成 toolResult 消息回填进上下文。工具的约定正是「失败就抛,别把错误编码进 content」(types.ts:451),统一转错误结果的活由循环干。查不到工具、校验失败、被 beforeToolCall 拦截也都汇到同一个终点(图 5.3-2)。
  3. 因为 stopReason === "length" 意味着模型的输出被 token 上限截断了,而流式的工具参数是用「尽力而为的 JSON 抢救解析器」拼出来的——一条被截断的消息完全可能产出能通过 schema 校验、但语义上残缺的参数。想象 bash 收到半截命令、write 收到半截文件内容:执行它们的代价远大于让模型重发一次。所以这一批 toolCall 一个都不执行,failToolCallsFromTruncatedMessage 一律返回错误结果,并在文案里让模型用完整参数重发。
  4. 因为两者服务于两个不同的目的,是刻意分开的:tool_execution_end 事件按「谁先跑完谁先播报」发出,是为了终端界面的实时感——快的工具不必等慢的;而写进历史的工具结果严格保持模型请求时的源顺序,是为了上下文可复现——同一批调用不管这次谁快谁慢,落进消息历史的顺序都一样,会话重放才不会漂移。这条行为由测试 "should emit tool_execution_end in completion order but persist tool results in source order" 固化。

本书分析的 Pi 版本:earendil-works/pi@16787ad(2026-09-21)