1. 模型提出工具请求后,谁来执行?
继续前面的任务:“读取 README.md,告诉我项目怎样启动。” 第 02 章讲到,模型接入层把服务返回的内容组装成完整回复,再交给 Agent 循环。如果回复中包含对 read 工具的调用,Agent 就会根据其中的文件路径执行读取,并在下一次调用模型服务时,把读取结果交回模型。
如果接下来让 Agent 运行项目里的测试,它就需要调用执行命令的 bash 工具。read 和 bash 两个工具做的事情不同,但 Agent 调用它们的流程相同:先找到对应工具、检查参数,再执行工具,最后把结果交回 Agent 循环。工具系统为这些步骤提供统一约定:每个工具按相同的接口接入,调用方按同一套流程使用它。 本篇用 read 和 bash 的核心代码,解释这套约定如何落到实现中。
2. 从工具定义到调用执行
read 负责读取文件,bash 负责执行命令。Agent 循环怎样通过同一套流程调用它们?先把整个过程分成两个阶段:
调用模型前,准备好可用工具:
- 为每个工具定义名称、用途、参数要求和执行函数。接口定义见
tools/types.ts。 - 确定本次运行可用的工具集合,相关初始化见
runner.ts。模型接入层将工具的名称、用途和参数要求发送给模型,执行函数保留在本地。
模型回复中包含工具调用时,处理逻辑见 loop.ts:
- 根据工具名称找到对应实现,检查调用参数是否符合要求,以及本次操作是否允许执行。
- 执行工具,将输出或错误整理成统一的结果,交回 Agent 循环。
第 02 章讲过,模型接入层会把服务返回的内容转换成 Agent 内部统一的消息格式。下面先看源码中,Agent 怎样识别回复里的工具调用,并找到对应工具来执行。
runAgentLoop:取出工具调用,交给执行流程。
// 文件:src/core/loop.ts
// 所在函数:runAgentLoop
// 节选:省略类型、事件通知及下方标出的步骤,不能直接运行。
// 初始化:tools 是本次可用的工具集合。
const toolMap = new Map(tools.map(t => [t.name, t]));
// ……省略调用模型、接收并保存回复等中间代码。
// 收到并保存模型回复后:取出其中的工具调用。
const toolCalls = assistant.blocks.filter(b => b.type === "toolCall");
// 省略没有工具调用、达到轮次上限时的结束判断。
// 下文只展示未被 max_tokens 截断的回复;截断时会拒绝整批调用,见第 02 下篇 4.4 节。
const results = [];
await executeToolBatch(
toolCalls,
toolMap,
signal,
onToolCall,
onEvent,
results,
clock
);
循环初始化时,用本次可用的 tools 建立 toolMap,保存工具名称与实现的对应关系。
模型接入层返回完整回复 assistant 后,Agent 循环从它的 blocks 数组中筛选出类型为 toolCall 的块。每个块描述一次工具调用。以读取 README 为例,其中包含:
name:"read",指定读取工具。arguments:{ path: "README.md" },告诉工具读取哪个文件。id:这次调用的标识,返回结果时带上它,让模型知道结果对应哪次调用。
这些调用保存在 toolCalls 数组中,接着交给 executeToolBatch 处理。
executeToolBatch:安排执行,收集调用结果。
// 文件:src/core/loop.ts
// 所在函数:executeToolBatchInner,由 executeToolBatch 调用。
// 节选:省略类型;此片段不能直接运行。
// 串行分支:处理第 i 个工具调用。
// 省略遍历、取消检查、串行/并发判断和事件通知。
const call = toolCalls[i];
const result = await executeToolCall(
call.id,
call.name,
call.arguments,
toolMap,
signal,
onToolCall,
grace
);
results.push(persistableResult(result));
一条回复可能包含多个工具调用,由 executeToolBatch 内部的 executeToolBatchInner 安排执行。这里节选串行分支中的一次调用:取出 toolCalls[i],把它的 ID、名称和参数交给 executeToolCall,等待返回后去掉仅供界面展示的字段,将结果加入 results。其中 grace 用于约束取消后的等待时间,这里先不展开。连续允许并发的调用则交给 executeChunk;两条路径都要查找工具、检查参数并经过执行前回调,具体调度在 2.5 节讨论。
executeToolCall:查找、检查并执行一次工具调用。
// 文件:src/core/loop.ts
// 节选:省略参数类型,依赖的辅助函数在后文展开。
async function executeToolCall(
id,
name,
args,
toolMap,
signal,
onToolCall,
grace
) {
const call = { id, name, arguments: args };
// ① 查找工具并检查参数,失败时直接返回错误结果。
const prepared = prepareToolCall(call, toolMap);
if ("result" in prepared) return prepared.result;
// ② 通过检查后,执行前回调仍可以阻止这次操作。
const decision = await onToolCall?.({ toolCallId: id, name, args });
if (decision?.block) {
return {
toolCallId: id,
toolName: name,
content: `Tool "${name}" blocked by ${blockSource(decision)}: ${decision.reason ?? "no reason given"}`,
isError: true,
};
}
// ③ runTool 调用对应工具的 execute;grace 约束取消后的等待。
const result = await grace.race(prepared.run(signal), name);
return { ...result, toolCallId: id };
}
进入 executeToolCall 后,先通过 prepareToolCall 查找工具并检查参数,再经过执行前回调 onToolCall;允许执行时,才调用准备好的 run 函数。prepareToolCall 和 runTool 的实现分别在 2.3、2.4 节展开。
调用 read 和 bash 时,都可以沿着这条流程找到对应实现,再通过统一的 execute 入口执行具体操作。工具怎样提供这些信息?先从接口看起。
2.1 定义工具:统一接口包含哪些信息
一个工具既要让模型知道如何使用,也要让本地知道如何执行。Ink 的 Tool 接口将这些信息放在同一个对象里:
// 文件:src/core/tools/types.ts
// 接口节选;结果类型在此展开,省略呈现钩子、命令退出码和 task 元数据等字段。
// ContentBlock 来自 src/core/messages.ts,具体结构见 2.4 节。
interface Tool {
name: string;
description: string;
parameters: TSchema;
concurrencySafe?: boolean;
execute(
args: Record<string, unknown>,
signal: AbortSignal,
context?: { toolCallId: string }
): Promise<{
output: string;
isError?: boolean;
content?: ContentBlock[];
display?: string;
}>;
}
前三项是工具的使用说明:
name:名称,模型用它指定要调用的工具。description:用途和使用限制,帮助模型判断什么时候该用它。parameters:输入规则,说明哪些字段必填、是什么类型。TSchema是 TypeBox 库表示这些规则的类型。
execute 是本地执行入口:接收参数和中断信号,异步返回输出与错误标记;结果还可以包含结构化的 content 和用于界面显示的 display。可选的 context 由调用层提供本次调用 ID,不是让模型填写的工具参数。concurrencySafe 则向调度方声明是否允许并发,后面再讨论。
例如,read 的参数需要文件路径,bash 的参数需要命令。工具内部处理方式不同,调用方却都通过同一个 execute 入口执行。以后增加一个工具,仍遵守这一约定,Agent 循环就不必了解它怎样操作外部环境。
接口之外还需要约定行为:取消后是否停止、错误怎样返回、输出会不会过大。只让函数签名相同,还不足以保证工具可以可靠地配合运行。
2.2 注册工具:让模型能选择,让 Agent 能执行
定义好工具后,还要把它加入本次运行。Ink 的 runner.ts 负责创建工具集合,再传给 Agent 循环:
// 文件:src/runner.ts
// 所在函数:RunnerImpl 的 constructor
// 工具初始化:省略其他内置工具与后续 task 工具注册。
this.tools = [
...(options.tools ?? [
createBashTool({ cwd: options.cwd }),
createReadTool({ cwd: options.cwd }),
// 其他内置工具……
]),
...(options.extensions?.tools ?? []),
];
这里的“注册”就是把工具对象纳入可用集合。上层可以提供自己的集合,也可以在内置工具之外追加扩展工具;扩展如何加载,留到第 09 章。
在《模型接入·上》的 2.2 节中,我们看过:模型接入层从 request.tools 中提取工具名称、用途和参数要求,转换后放进发给模型服务的 body.tools。这里的工具说明,就来自刚才创建的工具集合。集合中还保存着 execute 函数,Agent 会在本地用它执行模型选择的工具。
以 read 为例,看看同一个工具对象怎样用于提供说明和执行操作:
同一个 read 工具:模型怎样选择,Agent 怎样执行
- 创建工具
createReadTool({ cwd })对应上方初始化代码
- 返回 read 工具对象
name: "read"工具说明 + execute 函数 - 加入本次运行的 tools
tools包含 read、bash 等工具对象
加入集合后,同一个 read 工具对象既提供发给模型的说明,也保留供 Agent 执行的函数。
body.tools 中的 read 工具说明
name: "read"description: "读取文本文件或图片"parameters:path 是必填的字符串模型据此选择 read,并填写文件路径。
模型接入层从 request.tools 提取说明,按 Chat Completions API 格式转换后放入 body.tools。这里仅示意关键字段,转换过程见《模型接入·上》2.2 节。
const toolMap = new Map(
tools.map(t => [t.name, t])
);"read" → read 工具对象保留工具说明 + execute 函数Agent 据此找到工具,检查参数并执行。
映射保存的是完整工具对象,模型返回名称后就能找到对应实现。
模型接入层只提取需要发送的说明,本地仍保留完整工具对象。
- 解析出工具调用
name: "read"arguments: { path: "README.md" } - 按名称查找
toolMap.get("read")取出刚才保存的工具对象
- 检查通过后执行
tool.execute(args, signal)将文件路径交给读取函数
模型通过工具名称指定要调用的工具,Agent 也通过这个名称查找本地实现。因此,发给模型的工具说明需要与本地注册的工具保持一致。
注册时需要注意:说明与实现是否对应,名称是否重复。
两边没有对应好,会出现两种情况:
- 发出了工具说明,却没有注册对应实现:模型可能选择这个工具,但 Agent 无法在本次运行的
toolMap中找到它。 - 注册了实现,却没有发送工具说明:模型无法从提供的工具列表中得知这项能力。
同一次运行中,工具名称还应保持唯一,否则不同实现会争用同一个查找名称。当前 Ink 建立 toolMap 时,遇到同名工具会保留后加入的对象,覆盖前一个。如果希望在重名时直接报错,需要在注册工具时显式检查。
2.3 调用前检查:工具执行前要检查什么
模型选择了 read 工具,并给出了文件路径 README.md。执行读取之前,Agent 需要先检查:本次可用工具中有没有 read?它收到的参数是否符合工具定义的要求?
前面的 executeToolCall 会将这次工具调用的 ID、名称和参数整理成 call 对象,再调用 prepareToolCall(call, toolMap) 完成检查。其中,call 的内容如下:
{
"id": "t1",
"name": "read",
"arguments": { "path": "README.md" }
}
prepareToolCall 先根据 name 查找工具,再用该工具声明的参数规则检查 arguments。下面保留检查顺序和提前返回,省略错误结果中的 ID、名称及具体错误文字:
// 文件:src/core/loop.ts
// 错误结果只展示 isError 字段。
function prepareToolCall(call, toolMap) {
const tool = toolMap.get(call.name);
if (!tool) {
return { result: { isError: true /* 未知工具 */ } };
}
const record = call.arguments;
if (typeof record !== "object" || record === null || Array.isArray(record)) {
return { result: { isError: true /* 参数必须是 JSON 对象 */ } };
}
if (!Value.Check(tool.parameters, record)) {
// 源码通过 Value.Errors 收集字段错误,再放入结果。
return { result: { isError: true /* 不符合该工具的参数规则 */ } };
}
return { run: signal => runTool(call, tool, record, signal) };
}
prepareToolCall 找到工具后,会检查 call.arguments 是否符合该工具的参数规则。这里使用 TypeBox 的 Value.Check;校验失败时,源码还会用 Value.Errors 收集具体错误,节选中省略了这部分。
例如,{"path":123} 是合法的 JSON,但 read 要求 path 为字符串,因此这个参数无法通过校验。
检查结束后,prepareToolCall 返回两种对象之一,保存在 executeToolCall 的 prepared 变量中:
- 检查失败:返回
{ result: ... },其中包含错误结果,调用方直接返回该结果。 - 检查通过:返回
{ run: ... },其中的run函数用于后续执行工具。此时只是准备好了run函数,工具还没有执行。
参数检查通过后,工具还不会立即执行。executeToolCall 留出了一个执行前钩子 onToolCall,让调用方可以加入自己的处理,例如检查访问权限,或等待用户审批。权限检查和审批逻辑需要由接入方实现,具体会在第 08 章展开。
到这里,可以把一次工具调用涉及的检查归纳如下:
| 所在位置 | 检查内容 | 例子 |
|---|---|---|
prepareToolCall | 工具是否存在、参数结构和类型是否正确 | 能否找到 read,path 是否为字符串 |
onToolCall 回调 | 本次操作是否允许执行 | 是否允许读取指定位置 |
工具的 execute 实现 | 具体参数是否可用,以及实际操作能否完成 | 路径是否为空白、读取时文件是否存在 |
2.4 执行与反馈:怎样把输出或错误交回 Agent 循环
检查通过后,prepared.run 进入 runTool。这里才调用工具实现,并把工具输出整理成 Agent 循环使用的结果:
// 文件:src/core/loop.ts
// 省略参数类型,以及返回结果中的 taskRecord 元数据。
async function runTool(call, tool, record, signal) {
try {
const result = await tool.execute(
record,
signal ?? new AbortController().signal,
{ toolCallId: call.id }
);
return {
toolCallId: call.id,
toolName: call.name,
content: result.content ?? result.output,
isError: result.isError ?? false,
display: result.display,
};
} catch (err) {
return {
toolCallId: call.id,
toolName: call.name,
content: `Error: tool ${call.name} threw: ${err instanceof Error ? err.message : String(err)}`,
isError: true,
};
}
}
调用层补上 toolCallId、toolName,使结果与原工具调用对应。但结果不一定只是一段字符串:读取文本、读取图片和显示编辑差异,需要承载的内容不同。
模型使用的内容与界面显示的内容怎样区分? 工具返回值中的三个字段各有用途:
| 工具返回字段 | 调用层怎样处理 | 例子 |
|---|---|---|
output | 没有 content 时,把这段字符串作为结果的 content | 读取 README 后返回正文,运行命令后返回输出 |
content | 存在时优先采用,作为写入历史、供模型接入层转换的内容 | 文本说明与图片附件,也可以只有文本块 |
display | 随工具结果事件交给界面,作为显示内容;写入历史前移除 | 展示编辑前后的差异 |
当前 messages.ts 支持两种内容块:
type ContentBlock =
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string };
图片块的 data 是 Base64 编码的数据,mimeType 表示图片格式。这里的“结构化内容”指这两种明确的块类型,不是任意 JSON 对象。
注意 result.content ?? result.output 的含义:有 content 时,不会再把 output 自动追加进去。 如果图片之外还有模型需要知道的说明,工具必须把说明也放进 content 的文本块。
edit.ts 就利用了这一区分:编辑成功后,content 中只有类似 Edited src/app.ts: 2 edits applied. 的简短确认,display 中则包含各处修改的差异。用户能检查修改细节,下一次模型请求不用重复携带整份差异。界面没有收到 display 时,会通过 contentText 提取 content 中的文字;这个函数不会把图片的 Base64 数据当作正文显示。
无论工具主动返回失败,还是执行函数抛出异常,调用方都能拿到相同结构的结果。参数校验失败、执行前被拒绝时,工具虽然没有真正执行,调用层也会返回同样结构的结果。因此,Agent 循环可以统一收集这些结果并写入历史;具体失败原因则保存在 content 中。
接着看 Agent 循环怎样将这些结果写入历史:
// 文件:src/core/loop.ts
// 所在函数:runAgentLoop
// 节选:省略类型、中断后的结果补齐、结束判断与消息通知。
const results = [];
await executeToolBatch(
toolCalls,
toolMap,
signal,
onToolCall,
onEvent,
results,
clock
);
// ……省略中断后的结果补齐与结束判断。
const toolResults = { role: "toolResult", results };
history.push(toolResults);
executeToolBatch 在收集各次调用的结果时,通过 persistableResult 去掉仅用于显示的 display、durationMs,再放入 results。Agent 循环把这些结果组成一条 toolResult 消息,写入 history。因此,这两个显示字段不会随工具结果存进会话记录;content 中的文本和图片块则会保留。下一次调用模型时,模型接入层会将内部消息转换成服务要求的格式,具体过程见第 02 章。
统一结果格式时,也要保留判断操作结果所需的信息,例如命令的退出码和测试输出,不能只凭 isError 判断测试是否通过。
日志太长时,怎样既控制输出长度,又让模型能继续查阅? 假设一次测试产生了上万行输出。Ink 的 bash 不会把所有日志直接放进工具结果,而是分两步处理:
- 返回标准输出、标准错误各自末尾的有限内容,并注明发生了截断。
- 将更长的日志保存到临时文件,把路径一起返回。模型需要查看前面的报错时,可以再调用
read读取这个文件。
read 自身也有返回长度限制。读取普通的多行文本时,它会标明本次返回的行号范围,并提示下一次用哪个 offset 继续读取。例如,本次读到第 2000 行,下次就从 offset: 2001 开始。
保存日志也有上限:当前 bash 对标准输出、标准错误各保留最多前 10 MiB;超过上限时,会注明文件只保存了部分输出。因此,给出文件路径,不一定意味着全部日志都已保存。 这些限制发生在工具产生结果时;历史继续增长后如何选择与压缩信息,留到第 04 章展开。
调用层还会通过事件通知交互层更新执行状态。除了 tool_start、tool_end,并发路径还有区分实际开始与完成的事件,下一节会说明它们的作用;事件怎样驱动交互留到第 06 章,具体展示方式留到第 07 章。
2.5 工具调度:多个调用怎样安排执行
把前面的任务稍作扩展:“读取 README.md 和 package.json,告诉我项目怎样启动。” 假设模型在一条回复里给出了两次 read 调用。两次读取不需要彼此的结果,如果文件没有被同时修改,就可以一起执行,等两份内容都返回后再交给模型。
但“修改启动脚本,再运行测试”就有先后关系:测试必须在修改完成后执行。如果测试命令还需要根据修改结果决定,就应该先把修改结果交回模型,再由模型发起下一次调用。模型一次给出多个工具调用,并不代表它们都可以同时执行。
当前 Ink 怎样安排执行
沿用前面的例子,模型在一条回复中同时要求读取 README.md 和 package.json。Agent 循环取出这两次工具调用后,会交给 executeToolBatch,由它决定是依次执行,还是并发执行。
这个函数位于 loop.ts。它读取每个工具的 concurrencySafe 标记,先确定执行方式:
| 工具的标记 | 执行方式 |
|---|---|
未声明 concurrencySafe,或值为 false | 串行执行:等待当前调用结束,再处理下一个。 |
concurrencySafe: true | 将连续符合条件的调用归为一组,同时最多执行 5 个;有调用结束,就启动下一个等待中的调用。 |
当前内置工具中,read、grep、find、ls 和用于委派子代理任务的 task 都声明了 concurrencySafe: true;bash、edit、write 则仍串行执行。因此,前面读取 README.md 和 package.json 的两个调用,现在可以并发执行。
串行调用仍是前后两组之间的边界。 假设调用顺序是 read A → grep B → bash C → read D,那么 A、B 可以并发,但它们都结束后才能执行 C;C 结束后才能执行 D。调度器不会为了填满并发位置,越过 C 提前执行 D。
对于连续允许并发的一组调用,executeToolBatchInner 会交给 executeChunk 处理:
- 先检查整组调用:按调用顺序检查参数,并对检查通过的调用依次执行
onToolCall。整组检查结束后,才开始执行获准的调用。检查失败或被拦截的调用只产生错误结果,不占并发位置。 - 用滑动窗口安排执行:最多同时执行 5 个获准的调用。只要其中一个结束,就按原调用顺序取下一个等待中的调用补上,不必等正在执行的其他调用全部结束。
- 及时更新界面:并发路径中的
tool_start先登记调用,此时它可能还在等待检查或执行位置;实际开始执行时发送tool_running,执行结束时发送tool_settled。因此,界面可以及时显示某个调用已完成,执行耗时也不包含排队时间。 - 按调用顺序收集结果:从第一个尚未交付的调用开始,连续收集已经完成的结果,并依次发送
tool_end。遇到未完成的调用就停下来,等它结束后继续。结果顺序稳定,同时也不用每次都等整组结束才开始收集。
例如,一组有 A 到 G 共 7 个获准的读取调用,先启动 A 到 E。即使 A 很慢,只要 B 先结束,F 就可以开始,界面也能显示 B 已完成。不过,B 的 tool_end 和结果收集仍要等 A:等 A 完成后,才能按 A、B 的顺序交付;如果 C 也已完成,就继续收集 C。
这里要分清工具完成、结果收集和模型收到反馈三个时点。tool_settled 可以及时更新界面,tool_end 随有序结果一起交付;Agent 循环仍会等这一条模型回复中的工具调用全部处理完,再将 results 写入历史,供下一次模型请求使用。
当前设计的取舍与局限
默认串行、显式开启并发,让新增工具不会未经声明就参与并发;滑动窗口则减少了执行位置空闲时的等待。不过,这套机制仍有边界:
- 标记不分析具体调用之间的冲突。 只读工具可以一起执行,但这不保证读取期间没有外部写入,也不保证几次读取来自同一时刻的文件快照。两个
task即使操作同一份文件,也可能同时启动;task的说明要求任务相互独立,调度标记本身不会检查这一点。 - 上限约束的是当前这一组调用。 5 是这一层调度的并发上限,不是整个进程、所有会话或所有子代理共享的全局额度。
- 有序交付仍可能等待较早的慢调用。 后面的调用可以继续执行,也可以先在界面显示完成,但它们的
tool_end要按顺序交付。后续串行调用和下一次模型请求,也仍要等待前面的调用处理完。
因此,滑动窗口解决的是并发位置怎样持续利用的问题;是否存在调用依赖或资源冲突,仍需要额外判断。
更完整的工具调度需要考虑什么
concurrencySafe 解决的是“哪些工具允许并发”的初步判断。要进一步安排好多个工具调用,需要一套工具调度系统:接收一组工具调用,决定它们何时执行,最后收集每次调用的结果。目标是在满足执行约束的前提下,尽量减少等待。 工具输出是否正确,还取决于工具实现和传入参数,不能只靠调度保证。
设计这套系统,需要考虑以下几个方面:
- 调用依赖:哪些调用可以独立执行,哪些必须等待前面的结果?前一步失败后,后续调用是否继续?
- 资源冲突:多个调用是否会同时读写同一文件,或操作同一个外部资源?发生冲突时,如何安排顺序?
- 并发额度:同时执行多少个调用,才能兼顾速度、资源消耗和服务限流?
- 执行控制:调用开始前怎样检查参数和权限,执行过程中怎样处理取消、超时与失败?
- 结果交付:怎样将结果与原调用对应,及时反馈执行状态,并完整保留成功、失败或取消的结果?
当前 Ink 通过并发标记、滑动窗口和按调用顺序收集结果实现了基础调度,还没有根据具体调用的依赖与资源冲突来安排执行。后续是否扩展这些能力,可以结合实际任务的复杂度逐步决定。
3. 两个工具,怎样接入同一套系统
第 2 节的调用流程最终走到工具的 execute。接下来打开两个工具的实现,看看它们怎样接住参数和中断信号,又怎样把结果交回来。
3.1 read:接收路径,返回文件内容
read 既能读取文本,也能把支持的图片作为附件返回。先沿着读取 README 的路径看实现:下面节选自 read.ts,保留工具定义、路径检查、读取操作和文本结果;缩短了工具说明,省略 offset、limit 参数及校验、图片分支和内容裁剪细节。注释标出了省略位置,代码不能单独运行。
// 文件:src/core/tools/read.ts
// 模块级参数定义;下方为创建工具的函数。
const readSchema = Type.Object({
path: Type.String({
description: "Path to the file to read (relative or absolute)",
}),
// 省略 offset、limit 两个可选参数。
});
export function createReadTool(options: ReadToolOptions = {}): Tool {
const cwd = options.cwd ?? process.cwd();
return {
name: "read",
concurrencySafe: true,
description: "Read text files or attach supported images.", // 缩短后的说明
parameters: readSchema,
async execute(args, signal) {
const requested = String(args.path ?? "");
if (requested.trim() === "") {
return { output: "Error: no path given", isError: true };
}
const absolute = resolveReadPath(requested, cwd);
let bytes: Buffer;
try {
// 把调用层传来的中断信号继续交给文件读取操作。
bytes = await readFile(absolute, { signal });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
output: `Error reading ${requested}: ${message}`,
isError: true,
};
}
// 省略图片识别与处理分支,下文单独说明。
// 非图片文件继续进行二进制检查、解码、行范围检查与裁剪,此处省略。
// 这些代码从 bytes 得到 selected(正文)和 notes(裁剪说明)。
const output =
notes.length > 0 ? `${selected}\n\n${notes.join("\n")}` : selected;
return { output, isError: false };
},
};
}
沿着“读取 README”的请求看这段代码:
parameters告诉模型需要提供path,也让调用层可以检查它是不是字符串。execute继续检查字符串是否为空,并按工具创建时的工作目录解析路径。这些属于读取工具自己的规则。readFile执行读取,接收同一个signal;读取失败时,工具把错误原因放进output。- 文本读取成功时返回文件内容和
isError: false。工具不用填写结果中的调用 ID,也不用直接修改history,这些由前面的调用层和 Agent 循环完成。
因此,parameters 和 execute 各自负责一部分检查:前者描述参数结构,后者处理“这个路径能不能读取”等实际操作中的问题。
如果读取的是图片呢? readFile 得到字节后,工具先用 detectSupportedImageMimeType 根据文件内容识别支持的图片格式,而不是只看扩展名。命中图片分支后,processImage 负责格式转换、方向修正和按配置缩放;处理成功、大小符合限制时,返回文本说明与图片块:
// 文件:src/core/tools/read.ts
// execute 的图片分支末尾;省略识别、处理、大小限制与提示文字的生成。
const content: ContentBlock[] = [
{
type: "text",
text: `Read image file [${processed.mimeType}]${hints}${nonVisionNote}`,
},
{ type: "image", data: processed.data, mimeType: processed.mimeType },
];
return {
output: `Read image file [${processed.mimeType}]${hints}${nonVisionNote}`,
content,
};
这里没有把图片解码成文本正文,而是交给模型接入层转换成图片输入。处理失败或无法满足大小限制时,工具只返回省略图片的文字说明。
读到图片不等于当前模型能看图。 如果 Ink 判定当前模型不支持图片,模型接入层会在构造本次请求时,用文字占位替换图片块;这一步不修改已有历史中的图片块。工具负责取得内容,接入层负责按当前模型能力和协议发送,两者仍沿用前面的统一结果约定。
3.2 bash:把进程事件转换成工具结果
运行测试需要等待命令启动、输出内容、最后退出。虽然过程比读取文件复杂,对调用层来说,仍然是等待 execute 返回一个结果。
下面节选自 bash.ts 的 execute 方法,省略外层工具定义、参数检查,以及输出缓冲和停止逻辑的实现。StreamState 收集输出,settle() 则防止重复收尾,并清理计时器、取消监听和进程跟踪记录;真正交付结果的是 resolve(result)。
// 文件:src/core/tools/bash.ts
// 所在函数:createBashTool 返回的工具对象的 execute 方法。
// 节选:省略外层定义、参数检查与辅助逻辑;此片段不能直接运行。
return new Promise(resolve => {
const child = spawn("/bin/bash", ["-c", command], {
cwd,
env: { ...process.env, INK: "1" },
detached: process.platform !== "win32",
});
if (child.pid !== undefined) trackDetachedChildPid(child.pid);
const stdout = new StreamState(),
stderr = new StreamState();
// 省略 timedOut、aborted、settled 等状态,以及 stop、settle 和监听注册。
child.stdout.on("data", (chunk: Buffer) => {
if (!settled) stdout.append(chunk);
});
child.stderr.on("data", (chunk: Buffer) => {
if (!settled) stderr.append(chunk);
});
child.on("error", err => {
if (settle()) {
resolve({
output: `Error: failed to spawn command: ${err.message}`,
isError: true,
});
}
});
void waitForChildProcess(child, { killSignal: signal }).then(
async ({ code, signal: exitSignal }) => {
if (!settle()) return;
const error = timedOut
? `command timed out after ${timeoutSec}s and was killed`
: aborted
? "command aborted by user"
: exitSignal
? `command terminated by signal ${exitSignal}`
: code === null
? "command ended without an exit status"
: undefined;
const output = await formatOutput(
stdout,
stderr,
command,
error,
code ?? undefined
);
resolve(
error
? {
output: `Error: ${error}. Partial output:\n${output}`,
isError: true,
}
: { output, isError: false, exitCode: code ?? undefined }
);
}
);
});
spawn 启动命令后,data 事件持续收集输出;启动失败由 error 处理,退出与取消则由 waitForChildProcess 协调,最后交付工具结果。调用层只需等待这个 Promise。
为什么不直接等待 close? 假设 shell 启动了一个后代进程,后者继承了标准输出管道。即使 shell 已经退出,只要后代仍持有管道,close 就可能迟迟不到。当前 wait-child.ts 主要根据进程的 exit 和输出流状态收尾:进程退出且两条输出流都结束时立即返回;否则等待输出连续安静 100 毫秒,后续数据会重置这个等待,但退出后的总等待最多 2 秒。这样既尽量保留末尾输出,也避免管道一直不关闭而卡住。
用户取消时,传入的 killSignal 会直接结束这段等待,不必等到 exit 或 close,随后返回中断原因和已收集的部分输出。结束等待与终止进程是两件事,终止动作由下面的 stop() 负责。
formatOutput 负责整理标准输出、标准错误和退出状态。注意正常退出路径中的 isError: false:它不能保证命令执行成功。非零退出码会写进 output,工具也额外返回 exitCode;当前 runTool 不会将 exitCode 单独转交给模型,因此模型仍需结合输出判断测试是否通过。
再看刚才省略的停止逻辑和取消监听,它们位于同一个 Promise 内部:
// 文件:src/core/tools/bash.ts
// 所在函数:createBashTool 返回的工具对象的 execute 方法
// 位于 new Promise 的回调内;省略状态变量的初始化。
const stop = () => {
if (settled || stopping) return;
stopping = true;
if (child.pid === undefined) return;
untrackDetachedChildPid(child.pid);
killProcessTree(child.pid);
};
const onAbort = () => {
aborted = true;
stop();
};
signal.addEventListener("abort", onAbort, { once: true });
在 macOS、Linux 这类 POSIX 平台上,前面的 detached: true 让命令拥有独立进程组。killProcessTree 会直接向这个组发送 SIGKILL,覆盖仍在同一组内的后代;组终止失败时,再尝试终止直接子进程。这里没有先发 SIGTERM、等 2 秒再升级的步骤。上文的 2 秒用于限制退出后的输出等待,两者含义不同。
这仍然是尽力终止:后代如果另建进程组或会话,就可能脱离原组;发送信号失败也不代表进程已经消失。工具返回“已中断”,不能当作所有后台进程均已停止的证明。
3.3 对照两个实现,哪些工作留在工具内部
| 接口约定 | read 的实现 | bash 的实现 |
|---|---|---|
| 描述参数 | 文件路径,可选的行范围 | 命令,可选的超时时间 |
| 执行操作 | 读取文件,整理文本或处理图片 | 启动进程,收集输出与退出状态 |
| 响应取消 | 把信号传给文件读取 API | 终止命令进程组,并结束输出等待 |
| 返回结果 | 文本、图片附件或读取反馈 | 命令输出、退出状态或运行错误 |
两者都通过 name、description、parameters 描述能力,通过 execute(args, signal) 执行操作并返回结果。新增工具时,把这几项约定接好,就能复用第 2 节的查找、参数校验和结果包装流程。工具内部仍要负责实际操作、资源清理和具体错误原因。
4. 异常处理:调用失败或中断后怎么办
工具调用返回结果时,实际操作可能还没有开始,也可能已经执行但失败了;如果中途取消或超时,还需要确认操作是否真的停止。下面分别看这三种情况,以及结果应当告诉模型哪些信息。
4.1 工具没有执行:怎样说明拒绝原因
模型调用 read 时传入了 {"path":123},读取还没开始,就在参数检查时被拒绝。此时只返回“执行失败”,模型无法知道该改什么;结果需要指出哪个参数不符合什么要求。
第 2.3 节省略了错误详情的构造。下面补上 prepareToolCall 中的这段代码,保留字段错误和调用标识,省略前后的其他检查:
// 文件:src/core/loop.ts
// 所在函数:prepareToolCall
// 节选:参数不符合工具规则时,构造错误结果;省略类型断言。
if (!Value.Check(tool.parameters, record)) {
const issues = [...Value.Errors(tool.parameters, record)]
.slice(0, 5)
.map(e => `${e.instancePath || "(root)"}: ${e.message}`)
.join("; ");
return {
result: {
toolCallId: call.id,
toolName: call.name,
content: `Error: invalid arguments for ${call.name} — ${issues}. Fix the arguments and retry.`,
isError: true,
},
};
}
Value.Errors 提供校验详情,这里最多取 5 条,将字段位置和错误原因写入 content。模型接下来才能根据反馈,把 path 改成字符串后重新发起调用。错误文字中的“retry”只是给模型的提示,这段代码本身不会重新执行工具。
其他拒绝也应给出对应原因:未知工具时,说明错误名称和可用工具;权限或审批未通过时,说明操作受到什么限制。参数可以修正,但权限拒绝不意味着换一种参数就能继续执行。
4.2 工具执行失败:怎样返回具体原因
工具开始执行后,可能遇到文件不存在、没有读取权限或命令执行失败。结果应保留具体原因,帮助模型判断是需要修正路径、向用户说明权限限制,还是根据命令输出继续排查。
第 3.1 节的 read 会将读取错误写入 output;第 3.2 节的 bash 会返回命令输出和退出状态。对于工具抛出的异常,第 2.4 节的 runTool 会捕获并整理成错误结果,这里不再重复代码。
执行失败不代表操作完全没有生效;如果需要再次执行,应先确认上一次调用是否已经产生修改。
4.3 工具被取消或超时:实际操作是否停止
如果测试迟迟没有结束,用户可能取消,也可能由工具在达到超时时间后停止。这里需要分别看:工具如何停止实际操作,以及循环愿意等待工具多久。
Bash 的命令超时。 第 3.2 节已经展示 stop() 和取消监听;同一个 execute 方法里的超时处理也会调用 stop()。timeoutSec 来自工具参数,未设置时不创建超时计时器:
// 文件:src/core/tools/bash.ts
// 所在函数:createBashTool 返回的工具对象的 execute 方法
// 位于 new Promise 的回调内;省略进程创建和后续收尾代码。
const timer =
timeoutSec === undefined
? undefined
: setTimeout(() => {
timedOut = true;
stop();
}, timeoutSec * 1000);
到期后,这段代码尝试终止命令进程组。随后进程等待结束,settle() 清理计时器与监听器,返回超时原因和已有输出。这个计时器不会调用整次运行的 abort():命令超时会成为一条工具错误结果,Agent 仍可继续请求模型决定下一步。
取消后,工具一直不返回怎么办? 工具接口提供了 signal,但不能强迫每个工具都响应它。比如扩展工具已经开始等待某个外部操作,却忽略了取消通知;即使循环不再安排后续调用,也可能一直停在当前工具的 await 上。
当前 loop.ts 为每批工具创建 createGraceRegistry,串行和并发执行都通过它等待结果。第 2 节的串行调用最终执行的是:
// 文件:src/core/loop.ts
// 所在函数:executeToolCall,参数检查和执行前回调之后。
const result = await grace.race(prepared.run(signal), name);
return { ...result, toolCallId: id };
运行正常时,它照常等待工具完成;只有收到取消通知、且有待完成的工具执行时,才启动 10 秒宽限计时。同一批执行共享这个截止点,不会为每个工具依次等待 10 秒。到期仍未返回的调用得到合成的错误结果,明确说明“已放弃结果,底层进程可能仍在运行”。迟到的真实结果会被丢弃,避免再次写入历史。
例如,两个并发工具都在执行时用户取消:A 在 1 秒后返回中断结果,B 一直不响应。循环会保留 A 的结果,在宽限计时到期后为 B 生成错误结果,并为尚未执行的调用补上中断说明。结果写入历史后,循环以 aborted 结束,不再请求下一次模型回复。
这里有三个边界:
- 放弃等待不会杀死操作。
grace.race只让循环取得一个可用于收尾的结果;实际停止仍由工具负责。 - 10 秒不是正常执行的超时。 没有取消通知时,慢工具不会因这个计时而被中断。
bash.timeout是另一套工具内部的限制。 - 计时只覆盖工具执行。 执行前的
onToolCall回调和模型请求不在这个范围内,不能据此承诺“任何阶段的取消都会在 10 秒内结束”。
即使操作已经停止,之前写入的内容也不会自动恢复。取消或超时都不等于回滚,再次执行前仍要确认已有修改。
5. 工具接入之后,下一次给模型看什么
回到读取 README 的任务,文件内容已经作为工具结果写入历史,模型可以据此回答怎样启动项目。但任务继续推进时,Agent 可能还会读取更多文件、运行测试,历史中也会不断增加代码和日志。下一次调用模型,应该带上哪些内容,哪些可以省略或压缩?第 04 章接着讨论上下文管理。