跳到正文

Coding Agent 原理与实践

03:工具系统:定义、调用与反馈

更新于:

作者:折腾客

约 8,234 字318 行代码

文章目录

1. 模型提出工具请求后,谁来执行?

继续前面的任务:“读取 README.md,告诉我项目怎样启动。” 第 02 章讲到,模型接入层把服务返回的内容组装成完整回复,再交给 Agent 循环。如果回复中包含对 read 工具的调用,Agent 就会根据其中的文件路径执行读取,并在下一次调用模型服务时,把读取结果交回模型。

如果接下来让 Agent 运行项目里的测试,它就需要调用执行命令的 bash 工具。read 和 bash 两个工具做的事情不同,但 Agent 调用它们的流程相同:先找到对应工具、检查参数,再执行工具,最后把结果交回 Agent 循环。工具系统为这些步骤提供统一约定:每个工具按相同的接口接入,调用方按同一套流程使用它。 本篇用 read 和 bash 的核心代码,解释这套约定如何落到实现中。

2. 从工具定义到调用执行

read 负责读取文件,bash 负责执行命令。Agent 循环怎样通过同一套流程调用它们?先把整个过程分成两个阶段:

调用模型前,准备好可用工具:

  1. 为每个工具定义名称、用途、参数要求和执行函数。接口定义见 tools/types.ts。
  2. 确定本次运行可用的工具集合,相关初始化见 runner.ts。模型接入层将工具的名称、用途和参数要求发送给模型,执行函数保留在本地。

模型回复中包含工具调用时,处理逻辑见 loop.ts:

  1. 根据工具名称找到对应实现,检查调用参数是否符合要求,以及本次操作是否允许执行。
  2. 执行工具,将输出或错误整理成统一的结果,交回 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 怎样执行

① 把创建好的 read 工具加入 tools
  1. 创建工具
    createReadTool({ cwd })

    对应上方初始化代码

  2. 返回 read 工具对象
    name: "read"工具说明 + execute 函数
  3. 加入本次运行的 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 据此找到工具,检查参数并执行。

映射保存的是完整工具对象,模型返回名称后就能找到对应实现。

模型接入层只提取需要发送的说明,本地仍保留完整工具对象。

③ 模型选择 read 后:用名称找到实现
  1. 解析出工具调用
    name: "read"arguments: { path: "README.md" }
  2. 按名称查找
    toolMap.get("read")

    取出刚才保存的工具对象

  3. 检查通过后执行
    tool.execute(args, signal)

    将文件路径交给读取函数

工具名称把模型看到的说明与本地执行函数对应起来。图中代码与数据用于说明机制,省略了完整 API 包装、参数规则和部分调用字段。

模型通过工具名称指定要调用的工具,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 不会把所有日志直接放进工具结果,而是分两步处理:

  1. 返回标准输出、标准错误各自末尾的有限内容,并注明发生了截断。
  2. 将更长的日志保存到临时文件,把路径一起返回。模型需要查看前面的报错时,可以再调用 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 处理:

  1. 先检查整组调用:按调用顺序检查参数,并对检查通过的调用依次执行 onToolCall。整组检查结束后,才开始执行获准的调用。检查失败或被拦截的调用只产生错误结果,不占并发位置。
  2. 用滑动窗口安排执行:最多同时执行 5 个获准的调用。只要其中一个结束,就按原调用顺序取下一个等待中的调用补上,不必等正在执行的其他调用全部结束。
  3. 及时更新界面:并发路径中的 tool_start 先登记调用,此时它可能还在等待检查或执行位置;实际开始执行时发送 tool_running,执行结束时发送 tool_settled。因此,界面可以及时显示某个调用已完成,执行耗时也不包含排队时间。
  4. 按调用顺序收集结果:从第一个尚未交付的调用开始,连续收集已经完成的结果,并依次发送 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”的请求看这段代码:

  1. parameters 告诉模型需要提供 path,也让调用层可以检查它是不是字符串。
  2. execute 继续检查字符串是否为空,并按工具创建时的工作目录解析路径。这些属于读取工具自己的规则。
  3. readFile 执行读取,接收同一个 signal;读取失败时,工具把错误原因放进 output。
  4. 文本读取成功时返回文件内容和 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 章接着讨论上下文管理。