Appearance
2.2 工具(Tool)系统
工具是 Agent 与外部世界交互的"手"。pi 把工具定义成一套带 Schema 校验的接口,让模型能安全地调用任意功能。这一节讲清楚 AgentTool 契约、执行生命周期,以及如何写一个自己的工具。
先连回 2.1:工具在循环里被用在哪一步
在进入 AgentTool 细节前,先想清楚:工具在 2.1 核心循环 里被用在哪一步。
回顾 2.1 的循环,工具出现在两处:
ts
// ==== 位置 A:发给模型前的"工具清单" ====
const llmContext: Context = {
systemPrompt,
messages: llmMessages,
tools: context.tools, // ← 这里!把 AgentTool 列表"介绍"给模型
};
// 模型看到 tools,才知道"我可以调用这些工具"(见 2.1 的 Function Calling 一课)
// ==== 位置 B:模型发出 toolCall 后"真正执行" ====
const toolCalls = message.content.filter((c) => c.type === "toolCall");
if (toolCalls.length > 0) {
hasMoreToolCalls = !executedToolBatch.terminate;
// 进入 executeToolCalls → 最终调到 tool.execute() ← 工具真正干活的地方
}所以在前面的循环里,工具扮演两个角色:
| 循环里的位置 | 工具的角色 | 对应 2.2 的内容 |
|---|---|---|
| 位置 A(发请求前) | 作为 Context.tools,介绍给模型 | AgentTool 的 name / description / parameters |
| 位置 B(收到 toolCall 后) | 作为执行体,真正干活 | AgentTool.execute + 执行生命周期 |
一句话:2.1 的循环负责"模型提议调工具",2.2 的工具负责"真正执行"。两者通过 Context.tools(介绍)和 toolCall(请求)对接。
AgentTool 契约
packages/agent/src/types.ts:380:
ts
export interface AgentTool<TParameters extends TSchema, TDetails = any> extends Tool<TParameters> {
label: string; // 显示名
prepareArguments?: (args: unknown) => Static<TParameters>; // 参数兼容垫片
execute: (
toolCallId: string,
params: Static<TParameters>, // 已经过 Schema 校验的参数
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>, // 流式进度回调
) => Promise<AgentToolResult<TDetails>>;
executionMode?: "sequential" | "parallel";
}它继承自 @pi/ai 的 Tool(packages/ai/src/types.ts:491):
ts
export interface Tool<TParameters extends TSchema> {
name: string;
description: string; // 交给模型的说明
parameters: TParameters; // typebox Schema,用于生成 JSON Schema 与校验
constrainedSampling?: ...;
}核心:parameters 是 typebox Schema。pi 用它做两件事:
- 生成 JSON Schema 发给模型,让模型知道"该用什么参数"。
- 在模型发来参数后,校验参数是否合法。
发给模型的 tool 数据长什么样
Context.tools 里的 AgentTool 是运行时对象,但发给模型时不会原样发,而是被 API 实现层转换成 JSON Schema 格式。假设这样一个工具:
ts
const getWeatherTool: AgentTool = {
name: "get_weather",
description: "查询指定城市的天气",
parameters: Type.Object({
city: Type.String({ description: "城市名" }),
unit: Type.Optional(Type.Union([Type.Literal("celsius"), Type.Literal("fahrenheit")])),
}),
};模型真正收到的(以 OpenAI tools 参数为例):
json
{
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的天气",
"parameters": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "城市名" },
"unit": { "type": ["string", "null"], "enum": ["celsius", "fahrenheit"] }
},
"required": ["city"]
}
}
}
]
}关键点:
parameters(typebox Schema)被编译成标准 JSON Schema —— 模型靠它知道"该传哪些参数、什么类型、哪些必填"。- 只发
name+description+parameters,不发execute:
| AgentTool 字段 | 发给模型? | 原因 |
|---|---|---|
name | ✅ | 模型用它"点名"要调哪个工具 |
description | ✅ | 模型据此判断"什么时候该用" |
parameters | ✅(转 JSON Schema) | 模型据此生成合法参数 |
execute | ❌ | 这是你的执行函数,不能发给模型 |
label | ❌ | 纯 UI 概念 |
executionMode / 钩子 | ❌ | 运行时逻辑,与模型无关 |
parameters是"可选性"的载体:Type.Optional(...)决定字段是否出现在required里。模型生成的参数必须满足required,否则参数校验会拒绝。
各家厂商的差异(api 字段为什么重要):不同协议对工具的编码略有不同,@pi/ai 的 API 实现层负责把 Context.tools(统一形式)转成各家协议要求的格式,也把模型回复的 toolCall 反向转回 pi 统一形式:
| 协议 | 工具格式 |
|---|---|
OpenAI openai-completions / openai-responses | tools: [{type:"function", function:{...}}] |
Anthropic anthropic-messages | tools: [{name, description, input_schema}] |
Google google-generative-ai | tools: [{functionDeclarations: [...]}] |
一句话:发给模型的
tools是"介绍信",只含name+description+parameters(JSON Schema),不含execute。模型据此生成合法的toolCall参数;@pi/ai的 API 实现层负责格式转换。
工具执行的生命周期
一个工具调用经历 prepareToolCall → execute → finalize 三个阶段(agent-loop.ts):
mermaid
flowchart LR
TC[assistant 消息里的 toolCall] --> PREP[参数校验/准备]
PREP -->|beforeToolCall 返回 block| BLK[发错误结果]
PREP -->|通过| EXEC[执行 execute]
EXEC --> FIN[afterToolCall 改写结果]
FIN --> RES[toolResult 消息回填]prepareToolCall (agent-loop.ts:600)
├─ 找到同名工具;找不到 → 发"Tool not found"错误结果
├─ prepareArguments 垫片
├─ validateToolArguments 用 Schema 校验
├─ beforeToolCall 拦截(可 block)
└─ 返回 { kind: "prepared", tool, args }
executePreparedToolCall (agent-loop.ts:666)
└─ tool.execute(toolCallId, args, signal, onUpdate)
├─ 正常返回 → { result, isError: false }
└─ 抛错 → { result: 错误结果, isError: true }
finalizeExecutedToolCall (agent-loop.ts:709)
└─ afterToolCall 可部分改写 content / isError / usage / terminate注意一个细节:工具应通过"抛错"报告失败,循环会把它转成错误结果消息,而不是让 Agent 崩溃。
工具的顺序依赖怎么表达
工具之间的依赖要分两种情况,pi 分别用不同机制处理。
情况一:只是"不要并发跑"(避免冲突 / 保证先后)
如果多个工具调用不该同时执行(比如都写同一个文件、共享某资源),用声明来约束:
ts
// 方式 A:单个工具声明串行
const writeTool: AgentTool = {
name: "write",
description: "写入文件",
parameters: Type.Object({ path: Type.String(), content: Type.String() }),
executionMode: "sequential", // ★ 这个工具必须串行执行
async execute(toolCallId, params) { ... },
};
// 方式 B:全局设置所有工具串行
const agent = new Agent({ streamFn, toolExecution: "sequential", ... });循环里的选择逻辑(agent-loop.ts:411):
ts
const hasSequentialToolCall = toolCalls.some(
(tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential",
);
if (config.toolExecution === "sequential" || hasSequentialToolCall) {
return executeToolCallsSequential(...); // 一个执行完再执行下一个
}
return executeToolCallsParallel(...); // 都准备好后并发执行
sequential只保证"同一批 toolCall 一个接一个执行",但参数相互独立 —— 它不解决"B 需要 A 的结果"。
情况二:真正的数据依赖(B 需要 A 的结果当参数)
这种情况工具定义层面不需要、也无法表达,而是靠模型自己决定。因为工具结果(toolResult)都回填进了上下文,模型能看到前面工具的结果:
第 1 轮:模型发出 toolCall A
→ 循环执行 A → toolResult_A 回填
第 2 轮:模型看到 toolResult_A,据此发出 toolCall B(参数带上 A 的结果)
→ 循环执行 B → toolResult_B 回填
第 3 轮:模型给出最终答案ts
// 你不需要写任何"依赖声明",只需提供两个独立工具:
const readTool = { name: "read", execute: ({ path }) => 读文件... };
const editTool = { name: "edit", execute: ({ path, replacement }) => 改文件... };
// 模型会自己先调 read,看到内容后再调 edit(把内容带进参数)两种机制怎么选
| 需求 | 用什么 | 是否需要声明 |
|---|---|---|
| 多个工具共享资源,不能并发 | executionMode: "sequential" 或全局 toolExecution: "sequential" | 需要(声明串行) |
| B 的参数依赖 A 的结果 | 不用声明,靠模型多轮调用 | 不需要 |
| 两者都有 | 串行模式 + 模型多轮 | 声明串行即可 |
一句话:"只是别并发"用
executionMode: "sequential"(或全局toolExecution)声明;"B 依赖 A 的结果"则无需声明,靠模型在多轮循环里"看到 A 的结果后再调 B"自然达成。 pi 不提供"工具间参数依赖声明",因为模型本身就具备这种顺序推理能力。
工具结果的形状
AgentToolResult<TDetails>(types.ts:355):
ts
export interface AgentToolResult<TDetails> {
content: (TextContent | ImageContent)[]; // 返回给模型的内容
details: TDetails; // 结构化细节(UI/日志用)
usage?: Usage;
addedToolNames?: string[]; // 动态新增的工具
terminate?: boolean; // 提示"执行完这批就停"
}content 会被循环转成 toolResultMessage.content 回填给模型;details 则供 UI 渲染(例如 bash 的退出码、耗时)。
连回 2.1:
terminate字段直接控制循环的hasMoreToolCalls!在 2.1 里我们见到hasMoreToolCalls = !executedToolBatch.terminate。这里terminate就是某个工具结果里带的terminate: true。若该批次所有工具结果都设了terminate: true,即使模型还想调工具,内层循环也会停止 —— 这是"工具主动要求收工"的机制。
写一个自己的工具
ts
import { Type } from "@earendil-works/pi-ai";
const echoTool: AgentTool<{ message: string }> = {
name: "echo",
label: "回显",
description: "把输入的文本原样返回",
parameters: Type.Object({ message: Type.String() }),
async execute(_toolCallId, params) {
return { content: [{ type: "text", text: params.message }], details: {} };
},
};然后把它挂到 Agent 上:
ts
const agent = new Agent({ streamFn, initialState: { tools: [echoTool], ... } });
// 或运行时赋值
agent.state.tools = [echoTool];动态工具:addedToolNames
pi 支持工具在执行后动态引入新工具(例如 bash 工具执行时发现用户装了某个包管理器)。工具只需在结果里返回:
ts
return {
content: [...],
details: {},
addedToolNames: ["npm"],
};toolResultMessage.addedToolNames 会携带这些名字,供支持原生延迟加载的 provider 使用(见 packages/ai/src/types.ts:439)。
内置工具一览(coding-agent)
@pi/coding-agent 基于这套契约提供了真实工具:read、bash、edit、write、grep、find、ls(见 6.3 内置工具)。它们证明了这套契约足以支撑一个生产级编码 Agent。
小结
- 工具 =
name+description+parameters(JSON Schema)+execute。 - 生命周期:校验 →(拦截)→ 执行 →(改写)→ 回填。
- 失败用"抛错"表达,循环会优雅转成错误结果。
addedToolNames支持动态引入工具。
真实源码位置
AgentTool:packages/agent/src/types.ts:380Tool:packages/ai/src/types.ts:491- 执行生命周期:
packages/agent/src/agent-loop.ts:600-711
面试角度:为什么这样设计工具系统
Q1:为什么工具参数要用 JSON Schema(typebox)校验? 因为工具参数是模型(不可信)生成的。模型可能胡说参数、类型不对、丢字段。用 Schema 校验,能在执行前拦截非法参数,避免把脏数据传给真实函数(如 bash、edit)。校验失败返回错误结果,让模型自己修正 —— 这是"安全边界"的关键。
Q2:为什么工具用"抛错"报告失败,而不是返回一个错误对象? 因为抛错是 JS 天然的失败信号,能让调用方(executePreparedToolCall)统一捕获并转成 error 结果消息,而不用写一堆 if (result.error) 判断。同时它能保证"工具失败不会让 Agent 崩溃",失败被优雅地转述给模型。
Q3:为什么要有 beforeToolCall / afterToolCall 钩子?beforeToolCall 让宿主在工具执行前可拦截(比如危险命令 bash 需要用户确认);afterToolCall 让宿主可改写结果(比如脱敏、附加信息)。这两个钩子把"安全策略"和"输出加工"从工具实现里抽出来,工具本身保持纯粹。
Q4:为什么执行模式分 parallel 和 sequential? 因为工具调用之间可能有依赖或资源冲突。默认 parallel 提升吞吐(多个独立工具同时跑);sequential 适合有顺序依赖、或工具本身不该并发(如写同一文件)的场景。AgentTool 还能为单个工具单独声明 executionMode 覆盖全局策略。
下一步:2.3 生命周期事件。