前四章的模型只能根据对话内容回答。现在用户问:
我的下载目录里有哪些 PDF?
模型本身看不到本机目录。我们也不应该把整个文件系统交给模型。正确做法是给它一项边界清晰的能力:
当需要查询下载目录时,可以请求调用
list_download_files。
这一章先不用自动 Agent Loop。我们会手工完成一次完整往返:
- 把工具定义告诉模型;
- 模型返回
ToolCall; - 应用验证参数并执行本地代码;
- 应用把
ToolResultMessage放回 Context; - 再请求模型组织最终回答。
正因为这个流程有明显重复,下一章才会自然引出 Agent Loop。
1. Tool 不是一段可以被模型执行的代码
pi-ai 中的 Tool 只有四部分:
interface Tool<TParameters extends TSchema> {
name: string;
description: string;
parameters: TParameters;
constrainedSampling?: false | ConstrainedSamplingConfig;
}
源码位置:packages/ai/src/types.ts 中的 Tool
它描述的是模型可以请求什么,不包含本地 execute()。
flowchart LR
D["Tool Definition\n名字、描述、参数 Schema"] --> M["模型"]
M --> C["ToolCall\n我要调用什么、参数是什么"]
C --> A["桌面应用"]
A --> E["本地执行函数"]
E --> R["ToolResultMessage"]
R --> M
这是一个很重要的安全边界:
模型只能产生调用请求;真正执行文件系统操作的是应用程序。
pi-ai Tool 和 pi-agent AgentTool 不矛盾
下一章会看到 pi-agent 的 AgentTool:
import type { TSchema } from "@earendil-works/pi-ai";
interface AgentTool<
TParameters extends TSchema = TSchema,
TDetails = any,
> extends Tool<TParameters> {
label: string;
prepareArguments?: (args: unknown) => Static<TParameters>;
execute(
toolCallId: string,
params: Static<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>,
): Promise<AgentToolResult<TDetails>>;
executionMode?: "sequential" | "parallel";
}
它不是推翻 pi-ai Tool,而是在它上面增加 Agent Runtime 所需的本地执行契约:
Tool是 Provider 可见的协议定义;AgentTool继承Tool,再增加 UI 标签、参数预处理、执行函数和执行模式;AgentTool发送给模型时,Provider 仍只消费继承来的 Tool 定义字段。
本章先手工连接定义与执行,下一章再由 Agent Loop 消费 AgentTool[] 自动完成连接。
源码位置:packages/agent/src/types.ts 中的 AgentTool
2. 定义第一个工具
我们只允许模型选择文件扩展名,不允许它指定任意目录。下载目录由桌面主进程自己确定。
import {
Type,
type Static,
type Tool,
} from "@earendil-works/pi-ai";
const ListDownloadFilesParameters = Type.Object(
{
extension: Type.Optional(
Type.String({
description: "只返回该扩展名的文件,例如 .pdf",
pattern: "^\\.[A-Za-z0-9]+$",
}),
),
},
{
additionalProperties: false,
},
);
type ListDownloadFilesArgs =
Static<typeof ListDownloadFilesParameters>;
const listDownloadFilesTool: Tool<
typeof ListDownloadFilesParameters
> = {
name: "list_download_files",
description:
"列出当前用户下载目录中的文件。用户询问下载目录内容时使用。",
parameters: ListDownloadFilesParameters,
};
Type.Object() 来自 TypeBox。它生成 JSON Schema,同时 Static<...> 从同一份 Schema 推导 TypeScript 类型。
这样参数约束不会出现“两份定义慢慢不一致”的问题:
flowchart TD
S["TypeBox Schema"] --> J["发送给模型的 JSON Schema"]
S --> T["应用执行函数的 TypeScript 类型"]
S --> V["运行时参数验证"]
3. 为什么名称和描述很重要
Provider 会把 Tool 转换成各自的工具协议。无论目标是 OpenAI、Anthropic 还是 Google,传给模型的核心信息都来自:
namedescriptionparameters
因此工具描述不是普通 UI 文案,而是模型判断何时调用工具的上下文。
一个过于模糊的定义:
{
name: "files",
description: "处理文件",
}
会让模型难以判断它是读取、删除、搜索还是写入。
更好的描述应说明:
- 工具能做什么;
- 什么时候应该使用;
- 有哪些明确边界;
- 参数分别表示什么。
但描述只能影响模型决策,不能代替运行时权限控制。即使描述写着“只能读”,本地执行函数仍必须真的只做读取。
4. 把工具放进 Context
第一轮请求的 Context:
const context: Context = {
systemPrompt: [
"你是一个桌面助手。",
"需要本机信息时使用提供的工具,不要猜测。",
].join("\n"),
messages: [
{
role: "user",
content: "我的下载目录里有哪些 PDF?",
timestamp: Date.now(),
},
],
tools: [listDownloadFilesTool],
};
const firstReply = await models.completeSimple(model, context);
Context.tools 是工具定义数组。Provider 适配器会把它转换成自己的协议,而不是把 TypeBox 对象直接原样发给所有服务。
源码位置:packages/ai/src/types.ts 中的 Context
5. 同一份 Tool 怎样进入不同 Provider
以三种适配器为例:
| Pi Tool 字段 | OpenAI Responses | Anthropic Messages | |
|---|---|---|---|
name |
function/custom tool name | tool name | function declaration name |
description |
description | description | description |
parameters |
JSON Schema parameters | input_schema |
parametersJsonSchema |
源码位置:
packages/ai/src/api/openai-responses-shared.ts的convertResponsesToolspackages/ai/src/api/anthropic-messages.ts的convertToolspackages/ai/src/api/google-shared.ts的convertTools
适配并不意味着所有 Provider 能力完全一样。例如:
- strict JSON Schema 支持由 Provider/模型兼容信息决定;
- Anthropic 的非 strict 路径发送传统 object properties/required;strict 为
true时才把完整 Schema 合并进去; - Google 某些路径使用 OpenAPI 风格
parameters,默认路径使用完整parametersJsonSchema; - OpenAI 还支持受约束的 grammar 工具。
本章先使用普通 JSON Schema 工具。constrainedSampling 等高级能力留到生产化部分。
6. 模型返回的不是函数调用,而是 ToolCall
模型决定使用工具时,最终 AssistantMessage.content 中会出现:
interface ToolCall {
type: "toolCall";
id: string;
name: string;
arguments: Record<string, unknown>;
thoughtSignature?: string;
}
真实源码中的 arguments 当前使用较宽的 Record<string, any>;这里用 unknown 表达应用在验证前不应信任它。
例如:
{
"type": "toolCall",
"id": "call_abc123",
"name": "list_download_files",
"arguments": {
"extension": ".pdf"
}
}
id 用来把后面的 Tool Result 与这次调用关联起来。它不是工具名,也不是应用可以忽略的装饰字段。
模型请求工具时,最终消息通常使用:
firstReply.stopReason === "toolUse"
应用应根据实际 content 找出 Tool Call,而不是只看停止原因就假设一定只有一个调用。
const toolCalls = firstReply.content.filter(
(block) => block.type === "toolCall",
);
一条 Assistant Message 可以包含文字、Thinking 和多个 Tool Call。
7. Tool Call 也有流式生命周期
和文字一样,工具参数也可能逐段到达:
flowchart LR
S["toolcall_start"] --> D["toolcall_delta × N"]
D --> E["toolcall_end"]
E --> C["完整 ToolCall"]
事件定义:
type ToolCallEvent =
| {
type: "toolcall_start";
contentIndex: number;
partial: AssistantMessage;
}
| {
type: "toolcall_delta";
contentIndex: number;
delta: string;
partial: AssistantMessage;
}
| {
type: "toolcall_end";
contentIndex: number;
toolCall: ToolCall;
partial: AssistantMessage;
};
桌面界面可以在 toolcall_start 时显示“正在准备查询”,在 delta 到达时展示参数预览。
但不能在 toolcall_delta 时执行工具:
- JSON 可能还不完整;
- 后续 delta 可能继续改变参数;
- 响应可能最终因长度限制、错误或取消而停止。
只有完整调用结束并通过验证后才能执行。
还要等最终 Assistant Message 确认没有被截断。如果 stopReason 是 "length",即使出现了 toolcall_end,也不能执行其中任何 Tool Call。尽力修复后的残缺 JSON 可能恰好能解析甚至通过验证,但参数仍可能被静默截断。
源码中的 Agent Loop 会把这批调用全部转成错误 Tool Result,请模型重新发出完整调用。
源码位置:packages/agent/src/agent-loop.ts 中的 failToolCallsFromTruncatedMessage
8. Partial JSON 怎样尽力提供参数预览
流式参数可能依次长这样:
{
{"extension"
{"extension": ".p
{"extension": ".pdf"}
parseStreamingJson() 的策略是:
- 空输入返回
{}; - 先尝试普通 JSON,并修复字符串中的控制字符或无效反斜杠;
- 失败后用
partial-json解析未完成 JSON; - 再失败时尝试解析修复后的未完成 JSON;
- 全部失败则返回
{}。
源码位置:packages/ai/src/utils/json-parse.ts
OpenAI Responses、OpenAI Completions、Anthropic 和 Bedrock 等接收 JSON 文本 delta 的适配器,会累积字符串并不断更新 ToolCall.arguments;结束时再解析最终字符串,并移除内部临时字段。
Google Generative AI 的路径不同:SDK 事件已经提供结构化 functionCall.args,适配器用它构造 Tool Call,并补发统一的 start、delta、end 事件。不能把“累积 JSON 字符串”说成所有 Provider 的统一实现。
Partial JSON 的目的,是让 UI 有尽力而为的预览,不是证明参数已经安全。
9. 在执行前验证参数
pi-ai 导出 validateToolArguments():
import {
validateToolArguments,
type ToolCall,
} from "@earendil-works/pi-ai";
function validateListArgs(call: ToolCall) {
return validateToolArguments(
listDownloadFilesTool,
call,
) as ListDownloadFilesArgs;
}
它的源码流程是:
structuredClone()模型参数,避免直接修改原始 Tool Call;- 使用 TypeBox
Value.Convert()做类型转换; - 编译并缓存 Schema Validator;
- 对普通 JSON Schema 还有一层受控的递归 primitive coercion;
- 验证失败时,生成包含字段路径、原因和原始参数的错误。
例如模型给出:
{
"extension": 123
}
Schema 允许的情况下,转换层可能把它转换为字符串 "123"。不要把“有验证”误解成“绝不做类型修正”;执行函数接收的是验证并可能转换后的副本。
源码位置:packages/ai/src/utils/validation.ts
10. 实现真正的桌面查询
本地执行函数由应用自己编写:
import { readdir } from "node:fs/promises";
import { extname } from "node:path";
async function listDownloadFiles(
downloadsDir: string,
args: ListDownloadFilesArgs,
): Promise<string[]> {
const entries = await readdir(downloadsDir, {
withFileTypes: true,
});
const requestedExtension = args.extension?.toLowerCase();
return entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((name) => {
if (!requestedExtension) return true;
return extname(name).toLowerCase() === requestedExtension;
})
.sort((a, b) => a.localeCompare(b));
}
注意目录没有来自模型参数:
const downloadsDir = desktopPaths.downloads;
desktopPaths.downloads 代表桌面主进程通过可信平台 API 得到的下载目录。本书没有假定具体桌面外壳;Electron、Tauri 或原生宿主都可以提供这个值。
把 path 暴露给模型虽然更灵活,但能力边界会立刻从“列出下载目录”扩大成“读取任意目录”。这需要第 9 章的权限与路径保护设计。
11. 把执行结果变成 ToolResultMessage
成功结果:
const files = await listDownloadFiles(downloadsDir, args);
const result: ToolResultMessage = {
role: "toolResult",
toolCallId: call.id,
toolName: call.name,
content: [
{
type: "text",
text: JSON.stringify({ files }),
},
],
isError: false,
timestamp: Date.now(),
};
失败结果也应该回给模型:
const result: ToolResultMessage = {
role: "toolResult",
toolCallId: call.id,
toolName: call.name,
content: [
{
type: "text",
text: `查询失败:${message}`,
},
],
isError: true,
timestamp: Date.now(),
};
ToolResultMessage 的关键字段:
| 字段 | 作用 |
|---|---|
toolCallId |
必须对应原 Tool Call ID |
toolName |
标识工具名称 |
content |
发回模型的文字或图片 |
details |
可选的应用/UI 结构化数据 |
usage |
可选的工具自身 Usage,不计入主 LLM Context Usage |
isError |
告诉模型工具是否失败 |
addedToolNames |
延迟工具加载时,声明新增工具 |
本例只需要文字内容。details 可以给界面保存结构化文件列表,但 Provider 适配器主要把 content 转成模型可见结果。
12. 手工完成一次工具往返
把前面的步骤串起来:
import {
validateToolArguments,
type Context,
type ToolCall,
type ToolResultMessage,
} from "@earendil-works/pi-ai";
async function executeToolCall(
call: ToolCall,
): Promise<ToolResultMessage> {
if (call.name !== listDownloadFilesTool.name) {
return {
role: "toolResult",
toolCallId: call.id,
toolName: call.name,
content: [
{
type: "text",
text: `未知工具:${call.name}`,
},
],
isError: true,
timestamp: Date.now(),
};
}
try {
const args = validateToolArguments(
listDownloadFilesTool,
call,
) as ListDownloadFilesArgs;
const files = await listDownloadFiles(
desktopPaths.downloads,
args,
);
return {
role: "toolResult",
toolCallId: call.id,
toolName: call.name,
content: [
{
type: "text",
text: JSON.stringify({ files }),
},
],
isError: false,
timestamp: Date.now(),
};
} catch (error) {
desktopDiagnostics.capture(error);
return {
role: "toolResult",
toolCallId: call.id,
toolName: call.name,
content: [
{
type: "text",
text:
"无法查询下载目录:参数无效或目录暂时不可访问。",
},
],
isError: true,
timestamp: Date.now(),
};
}
}
async function answerWithOneToolRound(
context: Context,
) {
const assistant = await models.completeSimple(model, context);
context.messages.push(assistant);
const calls = assistant.content.filter(
(block) => block.type === "toolCall",
);
if (calls.length === 0) {
return assistant;
}
if (
assistant.stopReason === "error" ||
assistant.stopReason === "aborted"
) {
return assistant;
}
for (const call of calls) {
if (assistant.stopReason === "length") {
context.messages.push({
role: "toolResult",
toolCallId: call.id,
toolName: call.name,
content: [
{
type: "text",
text:
"工具调用没有执行:模型输出达到长度限制,参数可能被截断。请重新发出完整调用。",
},
],
isError: true,
timestamp: Date.now(),
});
} else {
context.messages.push(await executeToolCall(call));
}
}
const finalAnswer = await models.completeSimple(model, context);
context.messages.push(finalAnswer);
return finalAnswer;
}
这里的 models、model 来自前四章的模型运行时,desktopPaths 和 desktopDiagnostics 由桌面宿主注入。代码展示的是完整控制流,不假定某一种桌面外壳 API。
这里有意叫 answerWithOneToolRound():第二次模型回答仍可能再次请求工具,而这个函数不会继续执行。下一章会修复这个限制。
13. 消息顺序为什么不能打乱
工具往返后的 Context 应是:
sequenceDiagram
participant U as User Message
participant A as Assistant Message
participant T as ToolResult Message
participant N as 下一次模型请求
U->>A: 询问下载目录 PDF
A->>T: ToolCall id=call_abc123
T->>N: toolCallId=call_abc123
N-->>U: 根据真实结果组织回答
对应消息数组:
UserMessage
AssistantMessage(包含 ToolCall)
ToolResultMessage(引用 Tool Call ID)
AssistantMessage(最终回答)
不能只把工具结果当成一条普通 User Message。Provider 需要 Tool Call 与 Tool Result 的结构化对应关系。
如果一个 Assistant Message 产生多个 Tool Call,结果消息也需要分别保留各自 ID。
14. Tool Call ID 为什么需要标准化
不同 Provider 对 ID 的要求不同:
- OpenAI Responses 可能产生很长并含
|等特殊字符的复合 ID; - Anthropic 要求只包含字母、数字、下划线和连字符,并限制到 64 字符;
- OpenAI Chat Completions 的兼容路径对部分 ID 使用 40 字符限制;
- 某些 Google 托管模型也要求显式且受限的 ID。
Pi 的消息转换层允许目标 API 提供 normalizeToolCallId()。只有历史 Assistant Message 与目标模型不相同,并且目标适配器提供该回调时,它才会:
- 规范化 Assistant Message 中的 Tool Call ID;
- 记录旧 ID 到新 ID 的映射;
- 同步改写后续 Tool Result 的
toolCallId。
flowchart LR
O["原 ToolCall ID"] --> N["目标 Provider 规范化"]
N --> C["新 ToolCall ID"]
O --> M["ID 映射表"]
M --> R["同步改写 ToolResult ID"]
源码位置:
packages/ai/src/api/transform-messages.tspackages/ai/src/api/anthropic-messages.ts的normalizeToolCallIdpackages/ai/src/api/openai-completions.ts的normalizeToolCallIdpackages/ai/src/api/openai-responses-shared.ts的 ID 规范化packages/ai/src/api/google-shared.ts
桌面应用自己的职责仍然是:生成 Tool Result 时原样使用当前 Tool Call 的 id。跨 Provider 的历史适配由 Pi 处理。
转换会创建新的消息对象或内容块,不会把应用保存的原始会话 ID 就地改写。
15. 工具失败不等于应用崩溃
工具可能失败:
- 参数不符合 Schema;
- 下载目录不可访问;
- 文件系统调用被取消;
- 模型请求了不存在的工具。
本章的手工实现把这些失败转换成 isError: true 的 Tool Result,让模型可以解释失败或调整调用。
不要把执行异常吞掉后返回空数组。否则模型会误以为“目录中没有 PDF”,而不是“查询失败”。
同样,不要在错误结果里无条件暴露完整绝对路径、堆栈或秘密。发给模型与展示给用户的错误信息,应由应用做安全裁剪。这是桌面应用的责任。
16. Tool、ToolCall 和 ToolResult 的职责复盘
| 对象 | 谁创建 | 可信程度 | 作用 |
|---|---|---|---|
Tool |
应用 | 可信定义 | 告诉模型能力和参数格式 |
ToolCall |
模型 | 不可信输入 | 请求应用执行某项能力 |
| 验证后参数 | Pi 验证器 | 符合 Schema | 交给本地执行函数 |
ToolResultMessage |
应用 | 执行结果 | 把成功或失败反馈给模型 |
这条边界比“模型会调用函数”更准确:
模型生成结构化意图,应用验证后决定如何执行,并把结果重新纳入对话。
17. 为什么下一章需要 Agent Loop
当前代码只处理一轮工具调用。如果模型:
- 第一次列出文件;
- 第二次又请求读取其中一个文件;
- 然后才生成最终回答;
应用就要继续重复:
调用模型
→ 找 Tool Call
→ 验证
→ 执行
→ 追加 Tool Result
→ 再调用模型
还要处理多工具、失败、取消、事件、停止条件和队列。
这已经不是“一次 API 调用”,而是一个循环。下一章会直接走读 packages/agent/src/agent-loop.ts,把本章的手工代码替换为 Pi Agent。
18. 本章小结
pi-ai Tool是给模型看的能力定义,不包含本地执行函数。pi-agent AgentTool继承 Tool,再增加本地执行契约。- TypeBox Schema 同时服务于 JSON Schema、TypeScript 类型和运行时验证。
- 工具名称、描述和参数会被 Provider 转换到各自工具协议。
- 描述影响模型选择,但不能代替权限控制。
ToolCall是模型产生的不可信结构化请求。- 工具参数可以流式到达;Partial JSON 只用于预览。
- 只有完整调用结束并验证成功后才能执行。
- 最终 Assistant Message 以
length、error或aborted结束时,不得执行其中的 Tool Call。 validateToolArguments()会克隆、转换并验证参数。- Tool Result 必须引用对应 Tool Call ID。
- 成功和失败都应该形成结构化
ToolResultMessage。 - Pi 会在跨 Provider 回放时同步规范化 Tool Call 与 Tool Result ID。
- 固定下载目录而不是让模型传路径,可以保持最小能力边界。
- 一轮手工工具往返暴露了自动 Agent Loop 的必要性。
19. 自测
- 为什么
pi-ai Tool不直接包含execute()? - TypeBox Schema 在本章同时承担哪三种职责?
- 为什么不能在
toolcall_delta到达时立即执行? parseStreamingJson()失败时会怎样处理?validateToolArguments()会不会修改原 Tool Call 参数?- Tool Result 为什么必须保存
toolCallId? - 为什么不能把“目录读取失败”伪装成空文件列表?
- 本章为什么不允许模型提供任意
path? - 跨 Provider 时,谁负责同步改写 Tool Call 和 Tool Result ID?
- 当前手工实现遇到连续两轮工具调用会发生什么?
pi-ai Tool和pi-agent AgentTool分别服务哪一层?- 为什么收到
toolcall_end后仍要检查最终stopReason?
本章源码依据
packages/ai/src/types.tspackages/ai/src/utils/json-parse.tspackages/ai/src/utils/validation.tspackages/ai/src/utils/typebox-helpers.tspackages/ai/src/api/transform-messages.tspackages/ai/src/api/openai-responses-shared.tspackages/ai/src/api/openai-completions.tspackages/ai/src/api/anthropic-messages.tspackages/ai/src/api/google-shared.tspackages/ai/src/api/google-generative-ai.tspackages/agent/src/types.tspackages/agent/src/agent-loop.ts