夜雨聆风学习资料网

ARTICLE · 997587

从源码理解 Pi-05-Tool Calling:AI 的一句 JSON 怎样变成真正的文件和 Shell 操作?

从源码理解 Pi-05-Tool Calling:AI 的一句 JSON 怎样变成真正的文件和 Shell 操作?

05|Tool Calling:AI 的一句 JSON 怎样变成真正的文件和 Shell 操作?

模型说“我要读取 package.json”,和磁盘真的被读取,是两回事。

前者只是一段数据:工具名叫 read,参数里有一个文件路径。模型运行在远端 API 后面,它不能直接调用你电脑上的 fs.readFile(),更不可能隔着网络启动本地 shell。真正碰文件、创建进程的是 Pi 这个宿主程序。

麻烦都藏在两者之间:Pi 怎么知道 read 是哪个函数?模型少传了参数怎么办?命令退出码不是 0 算调用成功还是失败?执行结果为什么还要发回模型?更现实的一问是:既然模型能请求 bash,谁来限制它能做什么?

本文基于 Pi revision 2509b5c037d366979f2febfce4174b88aeaadc6a,日期为 2026-08-18。追踪当前 interactive CLI 使用的 AgentSession + Agent + agent-loop.ts 路径。不同 Provider 怎样表示 tool call 留到下一篇,这里从 Pi 已经归一化的数据结构开始。

先纠正一句常见说法

“模型调用了工具”是方便交流的简称。更准确的说法是:

模型提交调用申请→ Pi 验证申请→ Pi 执行本地代码→ Pi 返回执行回执

可以把三个对象记成:

Tool Definition = 说明书Tool Call       = 申请单Tool Result     = 回执

说明书告诉模型有哪些能力、每个能力接收什么参数;申请单由模型生成,只包含名字、调用 ID 和参数;回执由宿主生成,记录成功内容或错误。模型没有系统权限,权限始终属于运行 Pi 的进程。

标题里的“一句 JSON”也要稍微打个折扣。OpenAI、Anthropic、Google 的原始协议并不完全一样,参数还可能分多个 streaming chunk 到达。进入 Agent Core 时,pi-ai 已经把这些差异归一化成统一的 ToolCall:

interface ToolCall {  type: ”toolCall”;  id: string;  name: string;  arguments: Record;}

本篇说的 JSON,指的就是这层 normalized data,而不是某一家 API 的原始报文。

三层代码,各管一段

顺着源码看 Tool Calling,最容易把工具定义、Agent Loop 和文件操作揉成一团。当前 Pi 实际把它们放在三层:

@earendil-works/pi-ai 定义统一的 Tool、ToolCall 和 ToolResultMessage,Provider adapter 负责把它们翻译成厂商协议。

@earendil-works/pi-agent-core 不关心文件怎样读取。它处理的是通用调度:按名字找工具、校验参数、执行、捕获错误、发事件、包装结果。

@earendil-works/pi-coding-agent 才知道什么叫 read、bash、edit 和 write。真实的 fs/promises 与 child_process.spawn() 都在这一层。

这里还有个源码阅读上的小陷阱:Pi 并没有一个名为 ToolRouter 的核心类。AgentSession 用 Map 管完整工具表与 active selection;到了 agent-loop.ts,当前可用工具就是 AgentContext.tools 数组,路由动作是一句按 name 的 .find()。架构上存在“注册和路由”,不代表源码里一定要有一个同名对象。

模型当前到底能看到哪些工具?

当前 checkout 共有七个内置工具:

read, bash, edit, write, grep, find, ls

但默认激活的只有四个:read、bash、edit、write。grep、find、ls 可以通过 --tools、settings 或 SDK 打开;默认情况下,模型也可以通过 bash 运行相应 shell 命令。

这个区别很重要。“已经注册”不等于“本轮可调用”。AgentSession._refreshToolRegistry() 会合并 builtin、Extension 与 SDK custom tools,setActiveToolsByName() 再把选中的 AgentTool[] 写进 agent.state.tools。如果 Extension 注册了同名工具,当前 registry 还允许它覆盖 builtin。

Agent Loop 构造 Provider context 时只带 active tools:

return {  systemPrompt: context.systemPrompt,  messages: llmMessages,  tools: context.tools,};

所以 active selection 同时影响两头:模型请求里只声明这些工具,执行时也只在这些工具中查找。一个工具即使存在于完整 registry,只要没有 active,就不会因为模型猜对了名字而突然获得执行资格。

Tool Definition 不只是函数名

看 packages/agent/src/types.ts 的 AgentTool,一项可执行工具至少包含:

name + description + parameters + label + execute()

还可以提供 prepareArguments() 和 executionMode。Coding Agent 自己的 ToolDefinition 又增加了 system prompt 提示片段和 TUI renderer,随后由 wrapToolDefinition() 适配成 Agent Core 认识的 AgentTool。

以 read 为例,它的参数不是随便一个 object:

const readSchema = Type.Object({  path: Type.String(),  offset: Type.Optional(Type.Number()),  limit: Type.Optional(Type.Number()),});

这份 schema 有两份工作。

发请求时,Provider adapter 把它转成厂商需要的 function/tool schema。比如 OpenAI-compatible API 接收 function parameters,Anthropic 接收 input_schema。模型据此知道 path 必填、offset 和 limit 可选。

调用回来后,Runtime 还会用同一份 schema 做本地校验。即使 Provider 宣称支持 strict tool schema,宿主也不能把模型输出当成可信输入。API 兼容性、流式拼接、模型行为都可能产生不符合 contract 的参数。

Schema 解决的是“形状对不对”,不是“操作安不安全”。一个合法的 { command: "..." } 仍然可能是危险命令,这一点后面单独说。

跟一遍真实的 read 调用

假设 Provider 最终交给 Agent Core 的 block 是:

{  ”type”: ”toolCall”,  ”id”: ”call_42”,  ”name”: ”read”,  ”arguments”: {    ”path”: ”packages/coding-agent/src/core/tools/read.ts”,    ”offset”: 1,    ”limit”: 80  }}

它变成文件内容,要经过下面这条链:

先按名字找,而且只找 active tools

入口在 packages/agent/src/agent-loop.ts 的 prepareToolCall():

const tool = currentContext.tools?.find(  (t) => t.name === toolCall.name,);if (!tool) {  return {    kind: ”immediate”,    result: createErrorToolResult(`Tool ${toolCall.name} not found`),    isError: true,  };}

找不到不会去全局反射一个同名函数,也不会把模型提供的名字拼进 eval()。Runtime 直接生成错误回执。

这一步看起来普通,却是 Tool Calling 最重要的控制权之一:模型只能选择宿主事先交给它的能力,不能自己创造执行器。

有兼容预处理,再做 schema validation

找到工具后,可选的 prepareArguments() 先运行。当前内置 edit 用它兼容一些真实模型行为:把旧版顶层 oldText/newText 折进 edits[],把单个 edit object 包成数组,也尝试解析字符串化的 edits。

预处理之后,packages/ai/src/utils/validation.ts 的 validateToolArguments() 才接手。它 clone 参数,清理 optional field 中不合适的 null,按 schema 尝试类型转换,再用 TypeBox 编译出的 validator 检查。失败信息会带字段路径和收到的原始参数。

这套流程比一句 schema.parse(args) 稍微宽容,因为模型偶尔会把数字写成字符串,或者给可选字段填 null。但宽容有明确终点:转换后仍不满足 schema,就不执行工具。

校验异常也不会直接炸掉整个 Agent Loop。prepareToolCall() 把它捕获成 isError: true 的 tool result。模型下一轮能看到“缺少 path”或“字段类型错误”,然后重新申请。

Extension 可以在执行前拦截

schema 通过后,Agent Core 调用 beforeToolCall。Coding Agent 在 AgentSession._installAgentToolHooks() 中把它接到 Extension 的 tool_call event。

这给产品层留下了一个策略入口。例如 Extension 可以检查 bash.command,要求用户确认;也可以保护某些路径,返回:

{ block: true, reason: ”Protected path” }

当前 contract 还允许 handler 原地修改已校验参数,后面的 handler 和 execute() 都会看到修改值。不过修改后不会再次跑 schema validation。这个能力面向受信任的本地 Extension,不应该误解成 Runtime 自动提供的权限系统。

还有一个偏安全的细节:tool_call handler 如果抛错,Coding Agent 会阻断执行,而不是忽略 Extension 故障后继续碰环境。

到 execute(),本地副作用才真正发生

通过前置阶段后,executePreparedToolCall() 才调用:

prepared.tool.execute(  prepared.toolCall.id,  prepared.args,  signal,  onUpdate,);

read 的 execute() 会解析相对或绝对路径,检查可读性,再调用 fs/promises.readFile()。文本支持从 1 开始的 offset 与 limit,默认从文件头返回,并受行数和字节数上限约束;文件太大时,结果会告诉模型从哪个 offset 继续。图片则会处理成 image content,供支持视觉输入的模型使用。

也就是说,从模型视角看,它“读了文件”;从操作系统视角看,是本地 Node.js 进程用自身权限打开了文件。

bash 为什么不只是 exec(command)

bash 的 schema 很小:必填 command,可选 timeout。真正的实现却要处理 Coding Agent 才会遇到的问题。

createLocalBashOperations() 先解析当前平台的 shell 配置,再通过 child_process.spawn() 创建子进程。stdout 和 stderr 都进入同一个 output accumulator;AbortSignal 会触发进程树终止;可选 timeout 到期也会杀进程。默认没有超时,除非调用参数给出。

输出不能无限塞回上下文。当前实现保留最后一段输出,因为编译器和测试命令最有用的错误经常出现在尾部。超出行数或字节限制时,完整输出会写入临时文件,返回文本附上路径。这比静默截断好得多:模型至少知道自己看到的不是完整日志,也有机会继续读取原始输出。

非零 exit code 会让工具抛错,已有 stdout/stderr 和 Command exited with code N 一起进入错误文本。这里“抛错”不是让进程崩溃,而是在 Tool contract 中标记执行失败,交给 Agent Core 统一包装。

文件修改还多了一层并发保护

write 很直接:创建父目录,然后创建或覆盖整个文件。edit 更保守:每个 oldText 必须在原文件中唯一,多项 edit 必须互不重叠,而且都基于同一份原始内容匹配。任一 edit 失败,不应只写入前半批修改。

但 Agent Core 默认允许一条 assistant message 中的多个工具并行执行。如果模型同时发出两个针对同一文件的 edit,两个执行器都先读旧内容,再各自覆盖,后完成的那个可能吞掉前一个结果。

Pi 没有因此把所有工具全改成串行。edit 和 write 进入 withFileMutationQueue(),按解析后的文件路径排队:同一个文件的 mutation 串行,不同文件仍可以并行。能解析到同一 realpath 的符号链接别名也共用 queue。

这个设计处理的是很具体的竞争,而不是泛泛追求“线程安全”。粒度太大,多个独立文件修改会白白等待;粒度太小,同文件会丢更新。按文件路径串行是两者之间的折中。

Tool Result 为什么必须回到模型?

工具成功后返回 AgentToolResult;工具抛错后,Agent Core 也会创建一个 error result。可选的 afterToolCall 随后运行,Coding Agent 用它接 tool_result Extension event,并在结果写入 transcript 前规范化图片。

createToolResultMessage() 最终组装的消息包含:

role: ”toolResult”toolCallId: ”call_42”toolName: ”read”content: [...]isError: falsetimestamp: ...

还可能有 details、usage 和动态工具信息。content 是模型下一次判断最需要的反馈,details 更适合 UI、日志与 Session 使用,不能假设每个 Provider 都会把内部 details 原样发送给模型。

toolCallId 则负责配对。一次 assistant response 可以同时请求多个工具,光看 toolName 不足以判断哪个结果对应哪次调用。

Loop 把 ToolResultMessage 追加进 currentContext.messages,下一次模型请求就会包含:

AssistantMessage(tool call: call_42)ToolResultMessage(toolCallId: call_42, file content)

没有这一步,工具只是在本地打印了一段日志,模型依然不知道文件内容,更不可能据此决定下一步 edit。Tool Result 是 Agent 反馈闭环中的正式消息,不是 UI 附件。

错误同样如此。路径写错、oldText 不唯一、测试退出码非零,都可能是可恢复的中间状态。把失败明确标成 isError: true 再回灌,模型可以换路径、缩小匹配范围或分析测试输出。若遇到模型输出被 token limit 截断且其中带 tool call,Pi 更进一步:整批调用都不会执行,而是生成错误结果,让模型重新发出完整参数。一个被截断但碰巧还能解析的路径或命令,不值得冒险执行。

Schema validation 不是 sandbox

到这里必须把安全边界说清楚。

Pi 的官方安全文档明确写着:没有内置 sandbox。Built-in tools 和 Extensions 都以启动 Pi 的本地用户权限运行。read、edit、write 的路径可以是相对路径,也可以是绝对路径;默认实现不会自动把访问锁死在 cwd 内。bash 启动的也是普通本地 shell。

所以至少要区分三层:

层
解决什么
不解决什么
Schema validation
参数字段和类型是否合法
命令或路径是否危险
tool_call
 Extension hook
组织自己的确认、allowlist、路径保护策略
默认并不存在,也不是强隔离
Container / VM / OS policy
真正限制文件、进程、网络和凭据
需要额外部署与配置

Pi 的取舍很直接:它是本地 coding harness,要调用真实工具链,也不把一个残缺的进程内限制包装成可靠安全边界。代价是用户必须知道它拥有多大权限。对不可信仓库、无人值守任务,或你不准备持续观察的生成代码,应该把整个 Pi 或工具执行放进 container、VM、micro-VM 或 policy-controlled sandbox,只挂载任务真正需要的目录和凭据。

这也解释了 Tool schema 为什么必要却不充分。它能拦住 { timeout: "abc" },拦不住 { command: "合法但危险的命令" }。

如果自己写一个最小版本

下面是教学化简版本,不是 Pi 的真实实现。它省略了 Provider streaming、并行批次、Extension hooks、abort、参数 coercion、输出截断和事件:

async function executeCall(call: ToolCall): Promise {  const tool = activeTools.get(call.name);  if (!tool) {    return errorResult(call, `Tool ${call.name} not found`);  }  try {    const args = validate(tool.parameters, call.arguments);    const result = await tool.execute(call.id, args);    return {      role: ”toolResult”,      toolCallId: call.id,      toolName: call.name,      content: result.content,      isError: false,      timestamp: Date.now(),    };  } catch (error) {    return errorResult(call, String(error));  }}

这个版本已经保留了不可再删的边界:只从 active registry 查找、本地校验不可信参数、由宿主执行、错误也返回 correlated result。

如果继续做成真正的 Coding Agent,我会按风险依次补:取消与超时、危险操作策略、输出上限、同资源并发控制、事件与审计。至于漂亮的 TUI renderer,可以晚一点;它不会改变执行边界。

本篇总结

  • 模型产生的是 ToolCall 数据,真正的文件与 Shell 操作由本地 Pi 进程执行。
  • 当前默认 active tools 是 read、bash、edit、write;Runtime 只按名字查找 active tools。
  • 同一份 schema 既描述模型可调用的参数,也用于本地 validation;工具错误会变成 isError: true 的结果回到模型。
  • Agent Core 管调用协议,Coding Agent tools 管 fs / spawn 副作用;edit 和 write 还按文件路径串行 mutation。
  • Schema 不是权限边界。Pi 默认没有内置 sandbox,真正隔离要靠可信策略层与 OS/container/VM。

相关学习资料