Skip to content

5.3 一次 Tool Call 的完整循环

本页分析版本earendil-works/pi@c13ffe12026-07-30

本章解决什么问题:5.2 走完了「问一句、答一句」的路径。现在把工具加进来——模型说「我要读文件」之后,到底是谁去读的文件、读到的东西怎么回到模型面前、循环什么时候才停。 前置知识3.4 工具调用(Tool Calling)3.5 Agent 与 Agent Loop5.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:193)。它的 content 是一个数组,元素可能是文本、思考内容,也可能是 Tool Call(源码事实):

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

argumentsRecord<string, any>——注意这个 any。模型生成的是 JSON 文本,流式解析出来的对象在这一刻还没有经过任何校验,它是不可信输入。这一点决定了后面一整段校验代码的存在理由。

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

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

ts
// packages/agent/src/agent-loop.ts:203-224(有省略)
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);
	}
}
await emit({ type: "turn_end", message, toolResults });
// …(省略:prepareNextTurn 快照、shouldStopAfterTurn、steering 轮询)
earendil-works/pi@c13ffe1第 203–224 行在 GitHub 查看 ↗
检测、分派、回填三件事挤在同一段:先把 toolCall 块过滤出来;没有就让 hasMoreToolCalls 保持 false,内层 while 下一轮判断不成立,循环自然收尾。

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

hasMoreToolCalls = !executedToolBatch.terminate 这一行还藏着第二个出口:工具可以在结果里设 terminate: true 请求提前停机。但判定很严格——只有整批工具结果都设了才算数(源码事实,packages/agent/src/agent-loop.ts:582-584shouldTerminateToolBatch)。

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

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

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

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

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

第三步:串行还是并行

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

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

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

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

earendil-works/pi@c13ffe1第 600–664 行在 GitHub 查看 ↗
一次 tool call 在真正执行前要过四关:按名字查工具、prepareArguments 兼容垫片、schema 校验、beforeToolCall 钩子。任何一关失败都转成 isError 的工具结果,而不是抛给上层。
ts
// packages/agent/src/agent-loop.ts:607-618(有省略)
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 检查;619-650 行)
} catch (error) {
	// …(省略:657-663 行,把异常转成错误工具结果)
}

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

校验本身在 pi-ai 包里:

packages/ai/src/utils/validation.ts · validateToolArguments
earendil-works/pi@c13ffe1第 278–310 行在 GitHub 查看 ↗
先 structuredClone 再 Value.Convert 做类型强转(把 "3" 变成 3 这类),然后用编译过的 typebox validator 检查;失败时把每条错误的路径和原始参数一起拼进异常消息。

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

第五步:真正执行

packages/agent/src/agent-loop.ts · executePreparedToolCall
earendil-works/pi@c13ffe1第 666–707 行在 GitHub 查看 ↗
整个 agent-loop.ts 里唯一出现 tool.execute 调用的地方。它传入 toolCallId、校验后的参数、AbortSignal 和一个 onUpdate 回调;工具抛出的任何异常都在此被接住并转成错误结果。
ts
// packages/agent/src/agent-loop.ts:675-696(有省略)
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:371-377)。

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

Pi 的命令行界面(CLI)内置七个工具,pi --help 的真实采集输出(research/cli-captures/pi-help.txt:170-177)列出了它们:

text
Built-in Tool Names:
  read   - Read file contents
  bash   - Execute bash commands
  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)

grep / find / ls 后面标的「off by default」,对应源码里那份默认激活清单 ["read", "bash", "edit", "write"](源码事实,packages/coding-agent/src/core/agent-session.ts:2592-2594)——七个工具都在注册表里,但只有四个会进 context.tools 发给模型。

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

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

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

ts
// packages/coding-agent/src/core/tools/read.ts:266-305(有省略)
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)`);
}
// …(省略:277-286 行,用户显式给了 limit 时先按 limit 切片)
const truncation = truncateHead(selectedContent);
// …(省略:290-294 行,首行本身就超字节上限时,提示改用 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.]`;
	} // …(省略:302-304 行 else 分支:按字节上限截断时的另一种措辞)
	details = { truncation };
}

几个设计点:

  • 参数已经是可信的pathoffsetlimit 到这里时已过 typebox 校验,工具内部不再做类型判断,只做业务判断(比如 offset 越界)。
  • 失败就抛AgentTool.execute 的注释明确要求「失败时抛异常,而不是把错误编码进 content」(源码事实,packages/agent/src/types.ts:388);统一转错误结果的活由循环干。
  • 截断必须自带续读说明。这是全书反复出现的主题:上下文窗口是稀缺资源。
earendil-works/pi@c13ffe1第 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。中间的适配器是 wrapToolDefinitionpackages/coding-agent/src/core/tools/tool-definition-wrapper.ts:5-20),它把第 5 个参数用一个工厂函数补上。工具类型的三层结构在 6.4 工具系统细讲。

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

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

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

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

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

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

至于工具声明是怎么发给模型的:streamAssistantResponsecontext.tools 原样放进 LLM 上下文(packages/agent/src/agent-loop.ts:298-302),Anthropic 适配器再把每个工具的 typebox parameters 序列化成 input_schema 下发(packages/ai/src/api/anthropic-messages.ts:1313-1318)。同一份 schema 有三个用途:发给模型当声明、运行时当校验规则、编译期当 TypeScript 类型。

图解

图加载中…

图 5.3-1 把 5.2 的时序图扩展出工具回环
读法:从上往下是时间。和 5.2 相比多出来的是中间那一段——L 与 T 之间的往返,以及第二次发往 P 的请求。请重点看两处 Note:第一处对应 agent-loop.ts:203:206,第二处对应 :773:218-221。整张图里 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:586)。

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

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

伪代码里没有的Pi 里对应的实现为什么必须有
参数校验validateToolArgumentsvalidation.ts:278-310)+ prepareArguments 垫片(agent-loop.ts:586-598模型输出是不可信输入;类型错了要能让模型自己修
输出截断truncateHead / truncateTail / truncateLinetruncate.ts:78:168:268一个 10MB 的文件能一次撑爆上下文窗口
并发与顺序executeToolCalls 分派(agent-loop.ts:411-426)、并行版的顺序保持(:540-548多工具要快,但写文件类工具不能乱序
错误包装prepareToolCall 的 try/catch(:657-663)、executePreparedToolCall 的 catch(:697-703一次工具失败不该终止整个会话
截断消息保护failToolCallsFromTruncatedMessage:381-406半截参数比没有参数更危险
取消贯穿全链的 AbortSignal(:629-635:675-678:478-480用户按下 Esc 时,正在跑的 bash 也要停
钩子beforeToolCall / afterToolCall:619-643:709-754扩展要能拦截危险命令、改写结果

取消这条线索单独在 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 21 passed (21),耗时不到一秒(本机真实运行结果)。

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

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

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

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

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

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

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

本章小结

  • 模型不执行任何东西。它只在 assistant 消息的 content 里放一个 toolCall 块(packages/ai/src/types.ts:360-366);执行者永远是 harness,唯一的 tool.execute 调用点在 packages/agent/src/agent-loop.ts:675
  • 完整链路:streamAssistantResponse:193)→ 过滤 toolCall(:203)→ executeToolCalls 分派(:411-426)→ prepareToolCall 查找与校验(:600-664)→ executePreparedToolCall 执行(:666-707)→ finalizeExecutedToolCall 钩子(:709-754)→ createToolResultMessage:773-787)→ 回填 currentContext:218-221)→ 内层 while 再转一圈(:174)。
  • 循环的终止条件在本章只用到一个:这一轮没有 toolCall,hasMoreToolCalls 保持 false。另外三个(error/aborted、整批 terminate、shouldStopAfterTurn)在 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:203-224 / 381-406 / 411-426 / 600-664 / 666-707 / 773-792packages/ai/src/utils/validation.ts:278-310packages/ai/src/types.ts:360-366packages/coding-agent/src/core/tools/read.ts:203-347packages/coding-agent/src/core/tools/truncate.ts:11-13 / 78-160;测试 packages/agent/test/agent-loop.test.tspackages/coding-agent/test/tools.test.ts
  • 自测问题:① 模型一次返回了 3 个 toolCall,其中一个工具声明了 executionMode: "sequential",实际会怎么执行?为什么?② 工具 executethrow 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 与工具执行

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)