返回课程首页

5

让 Agent 查询桌面信息

从一个文件查询工具认识 ToolCall 与 ToolResult。

源码基线:Pi v0.82.0 · commit 518855d

前四章的模型只能根据对话内容回答。现在用户问:

我的下载目录里有哪些 PDF?

模型本身看不到本机目录。我们也不应该把整个文件系统交给模型。正确做法是给它一项边界清晰的能力:

当需要查询下载目录时,可以请求调用 list_download_files

这一章先不用自动 Agent Loop。我们会手工完成一次完整往返:

  1. 把工具定义告诉模型;
  2. 模型返回 ToolCall
  3. 应用验证参数并执行本地代码;
  4. 应用把 ToolResultMessage 放回 Context;
  5. 再请求模型组织最终回答。

正因为这个流程有明显重复,下一章才会自然引出 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 Toolpi-agent AgentTool 不矛盾

下一章会看到 pi-agentAgentTool

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,传给模型的核心信息都来自:

  • name
  • description
  • parameters

因此工具描述不是普通 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 Google
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.tsconvertResponsesTools
  • packages/ai/src/api/anthropic-messages.tsconvertTools
  • packages/ai/src/api/google-shared.tsconvertTools

适配并不意味着所有 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() 的策略是:

  1. 空输入返回 {}
  2. 先尝试普通 JSON,并修复字符串中的控制字符或无效反斜杠;
  3. 失败后用 partial-json 解析未完成 JSON;
  4. 再失败时尝试解析修复后的未完成 JSON;
  5. 全部失败则返回 {}

源码位置: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;
}

它的源码流程是:

  1. structuredClone() 模型参数,避免直接修改原始 Tool Call;
  2. 使用 TypeBox Value.Convert() 做类型转换;
  3. 编译并缓存 Schema Validator;
  4. 对普通 JSON Schema 还有一层受控的递归 primitive coercion;
  5. 验证失败时,生成包含字段路径、原因和原始参数的错误。

例如模型给出:

{
  "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;
}

这里的 modelsmodel 来自前四章的模型运行时,desktopPathsdesktopDiagnostics 由桌面宿主注入。代码展示的是完整控制流,不假定某一种桌面外壳 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 与目标模型不相同,并且目标适配器提供该回调时,它才会:

  1. 规范化 Assistant Message 中的 Tool Call ID;
  2. 记录旧 ID 到新 ID 的映射;
  3. 同步改写后续 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.ts
  • packages/ai/src/api/anthropic-messages.tsnormalizeToolCallId
  • packages/ai/src/api/openai-completions.tsnormalizeToolCallId
  • packages/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 以 lengtherroraborted 结束时,不得执行其中的 Tool Call。
  • validateToolArguments() 会克隆、转换并验证参数。
  • Tool Result 必须引用对应 Tool Call ID。
  • 成功和失败都应该形成结构化 ToolResultMessage
  • Pi 会在跨 Provider 回放时同步规范化 Tool Call 与 Tool Result ID。
  • 固定下载目录而不是让模型传路径,可以保持最小能力边界。
  • 一轮手工工具往返暴露了自动 Agent Loop 的必要性。

19. 自测

  1. 为什么 pi-ai Tool 不直接包含 execute()
  2. TypeBox Schema 在本章同时承担哪三种职责?
  3. 为什么不能在 toolcall_delta 到达时立即执行?
  4. parseStreamingJson() 失败时会怎样处理?
  5. validateToolArguments() 会不会修改原 Tool Call 参数?
  6. Tool Result 为什么必须保存 toolCallId
  7. 为什么不能把“目录读取失败”伪装成空文件列表?
  8. 本章为什么不允许模型提供任意 path
  9. 跨 Provider 时,谁负责同步改写 Tool Call 和 Tool Result ID?
  10. 当前手工实现遇到连续两轮工具调用会发生什么?
  11. pi-ai Toolpi-agent AgentTool 分别服务哪一层?
  12. 为什么收到 toolcall_end 后仍要检查最终 stopReason

本章源码依据

  • packages/ai/src/types.ts
  • packages/ai/src/utils/json-parse.ts
  • packages/ai/src/utils/validation.ts
  • packages/ai/src/utils/typebox-helpers.ts
  • packages/ai/src/api/transform-messages.ts
  • packages/ai/src/api/openai-responses-shared.ts
  • packages/ai/src/api/openai-completions.ts
  • packages/ai/src/api/anthropic-messages.ts
  • packages/ai/src/api/google-shared.ts
  • packages/ai/src/api/google-generative-ai.ts
  • packages/agent/src/types.ts
  • packages/agent/src/agent-loop.ts