给智能体接入第一个工具
定义并注册一个读取固定练习文件的工具,掌握 provider 对工具名与 schema 的硬性要求,以及结果各字段的可见范围。
给智能体接入第一个工具
模型不会因为知道文件名就能读取你的磁盘。要让助手解释本地代码,程序需要把读取能力注册成工具:模型提出调用请求,运行时检查参数,执行函数读取内容,再把结果送回模型。
本篇接入一个只读工具,只允许读取固定的 sample.ts。项目沿用最小项目里的 runtime.ts,不开放任意路径,也不注册命令执行或文件修改能力。
本文的工具接口定义核对自 Pi 官方仓库 6d3e342b 的 packages/agent/src/types.ts;工具名与 schema 的硬性要求来自官方示例 packages/agent/examples/mcp-codemode/tools.ts。
准备一份可核对的文件
在项目根目录创建 fixtures,新建 fixtures/sample.ts:
export function first<T>(items: T[]): T | undefined {
return items[0]
}这个函数足够短,可以手工检查模型解释是否符合源码。空数组时返回 undefined,但工具本身只读取文本,不会执行该函数。
工具接口的完整形状
AgentTool 的定义(源码注释从简):
// packages/agent/src/types.ts
export interface AgentTool<TParameters extends object = object, TDetails = any> extends Tool<TParameters> {
/** Human-readable label for UI display. */
label: string;
/**
* Optional compatibility shim for raw tool-call arguments before schema validation.
* Must return an object that matches `TParameters`.
*/
prepareArguments?: (args: unknown) => AgentToolParams<TParameters>;
/**
* JSON Schema of `structuredContent` in successful results. Tools that declare it should always
* set `structuredContent`.
*/
outputSchema?: object;
/**
* Execute the tool call. Throw on failure, or return a result with `isError: true`; do not only
* describe the failure in `content`.
*/
execute: (
toolCallId: string,
params: AgentToolParams<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>,
) => Promise<AgentToolResult<TDetails>>;
/** Recovery policy for an effect whose durable intent exists but whose outcome is unknown. */
replay?: "never" | "safe";
/** Per-tool execution mode override; "sequential" forces the whole batch to run one at a time. */
executionMode?: ToolExecutionMode;
}它继承的 Tool 提供三个字段:name、description、parameters。最小可用的工具只需要 name、label、description、parameters、execute 五个字段;其余四个是可选但各有用途的能力,本篇后面逐个讲。
两条 provider 侧的硬性要求
这两条不是 Pi 的建议,而是 provider 会直接拒绝的约束。官方示例的注释把它们写得很清楚。
工具名:64 字符,只允许 [A-Za-z0-9_-]
// packages/agent/examples/mcp-codemode/tools.ts
/** Provider tool names are limited to 64 characters of `[A-Za-z0-9_-]`. */
function toToolName(prefix: string, name: string): string {
return `${prefix}_${name}`.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
}注意这个函数做了两件事:把非法字符换成下划线,然后截断到 64 字符。它同时处理了"字符集"和"长度"两个限制。
本篇的工具名 read_lesson_file 只有 16 个字符,是合规的。但如果你要从 MCP 服务器批量导入工具名,就得照这个函数处理一遍。
schema 必须是 object,而且要有 properties
// packages/agent/examples/mcp-codemode/tools.ts
// Providers require an object schema, and some reject one without `properties`.
parameters: {
...tool.inputSchema,
type: "object",
properties: tool.inputSchema.properties ?? {},
},这句注释里有两个信息:必须是 object(不能是数组或标量),以及部分 provider 会拒绝没有 properties 的 schema。第二条尤其容易踩——一个"无参数"的工具如果写成 { type: "object" },在某些 provider 上会直接失败。官方示例的处理方式是补一个空对象兜底。
定义工具
创建 src/read-tool.ts:
import { readFile } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import type { AgentTool } from "@earendil-works/pi-agent-core"
const parameters = {
type: "object",
properties: {
file: { type: "string", enum: ["sample.ts"], description: "可读取的练习文件" },
},
required: ["file"],
additionalProperties: false,
} as const
// 初学示例只允许读取这一份固定文件,不接受任意磁盘路径。
export const readLessonFile: AgentTool<typeof parameters> = {
name: "read_lesson_file",
label: "读取练习文件",
description: "读取 sample.ts 的源代码。解释该文件前先调用此工具取得内容。",
parameters,
async execute(_toolCallId, params, signal) {
// 普通 JSON Schema 的静态推导可能是 unknown,执行器仍做显式收窄。
if (
typeof params !== "object" ||
params === null ||
!("file" in params) ||
params.file !== "sample.ts"
) {
throw new Error("只允许读取 sample.ts")
}
const path = fileURLToPath(new URL("../fixtures/sample.ts", import.meta.url))
const text = await readFile(path, { encoding: "utf8", signal })
return { content: [{ type: "text", text }], details: { file: "sample.ts" } }
},
}参数没有开放 path: 任意字符串,而是把 file 限定为一个枚举。执行函数始终读取相对模块位置确定的固定文件,不会把模型输入直接拼成磁盘路径。这是教学示例的最小权限范围;它不等于一个可供不可信项目使用的完整文件沙箱。
parameters 使用普通 JSON Schema,避免先引入额外 Schema 库。运行时会校验工具请求,执行函数也显式检查输入。直接调用 execute() 的代码可能绕过运行时,因此执行器本身保留清晰的参数边界。
label 是必填字段,注释说明它是 "Human-readable label for UI display."——给界面显示用,不发给模型。
结果的四个出口,谁看得见什么
AgentToolResult 的字段分工是这一节的核心:
// packages/agent/src/types.ts
export interface AgentToolResult<T = JsonValue | undefined> {
/** Text or image content returned to the model. */
content: (TextContent | ImageContent)[];
/** Arbitrary structured details for logs or UI rendering. */
details: T;
/** Machine-readable result matching the tool's `outputSchema`, for programmatic callers. */
structuredContent?: JsonValue;
/** Usage from the final tool execution itself, if available. */
usage?: Usage;
/** Report a failure without throwing. */
isError?: boolean;
/** Hint that the agent should stop after the current tool batch. */
terminate?: boolean;
}可见范围可以这样记:
structuredContent 默认不发给模型,注释写得很直接:"Not sent to the model; content remains the model-facing result."
那 details 发给模型了吗?也没有。看 provider 层的转换就知道——Anthropic 适配器把工具结果转成协议格式时只取三个字段:
// packages/ai/src/api/anthropic-messages.ts
function convertToolResult(msg: ToolResultMessage, content = msg.content): ContentBlockParam {
return {
type: "tool_result",
tool_use_id: msg.toolCallId,
content: convertContentBlocks(content),
is_error: msg.isError,
};
}details 不在其中。OpenAI 适配器同样只取 tool_call_id 和文本内容。
这两个方向的分工是:content 给模型看自然语言或图片;details 给程序看原始数据,日志、界面、后续断言都用它;structuredContent 给程序看机器可读结构,配合 outputSchema 声明形状,让调用方按字段消费而不必解析文本。
官方示例里 MCP 工具同时用后两者:outputSchema: tool.outputSchema 声明形状,structuredContent: result.structuredContent 填值,content 另走 toLlmContent(result) 给人读。details 和 content 都是必填的——返回 { content: [...] } 而不写 details 会是类型错误。
prepareArguments:校验之前的兼容垫片
模型发回来的参数形状常常和你的 schema 不完全一致。prepareArguments 就是为这种情况准备的:
prepareArguments?: (args: unknown) => AgentToolParams<TParameters>;注释是 "Optional compatibility shim for raw tool-call arguments before schema validation. Must return an object that matches TParameters."——在 schema 校验之前执行,返回值仍然要通过校验。
它在准备阶段被调用,位置在参数校验之前:
// packages/agent/src/agent-loop.ts
function prepareToolCallArguments(tool: AgentTool<any>, toolCall: AgentToolCall): AgentToolCall {
if (!tool.prepareArguments) {
return toolCall;
}
const preparedArguments = tool.prepareArguments(toolCall.arguments);
if (preparedArguments === toolCall.arguments) {
return toolCall;
}
return { ...toolCall, arguments: preparedArguments as Record<string, any> };
}在 prepareToolCall 里,垫片排在校验之前:prepareToolCallArguments(tool, toolCall) 之后才是 validateToolArguments(tool, preparedToolCall)。
所以 prepareArguments 可以做参数修复,但不能用来绕过 schema——修复后的参数照样要过 validateToolArguments。典型用途是把模型偶尔发错的形状掰回来,比如把 "42" 转成数字、把单个值包成数组。validateToolArguments 本身已经会用 pi-schema 的 parse() 做强制转换(注释说 "The arguments are coerced first"),但 prepareArguments 能处理更结构性的偏差:
export const readLessonFile: AgentTool<typeof parameters> = {
// ...
// 垫片:把模型偶尔发成数组的 file 修正回字符串。
// 返回值仍会经过 schema 校验,所以这里不需要自己校验。
prepareArguments(args) {
if (typeof args !== "object" || args === null || !("file" in args)) {
return args as never
}
const file = args.file
if (Array.isArray(file) && file.length > 0 && typeof file[0] === "string") {
return { ...args, file: file[0] } as never
}
return args as never
},
// ...
}一个实现细节:它必须返回一个新的对象。直接修改传进来的 args 会同时改掉 transcript 里那条 tool call 的参数记录。
replay:副作用已发生但结果未知时怎么办
/** Recovery policy for an effect whose durable intent exists but whose outcome is unknown. */
replay?: "never" | "safe";这个字段回答的是一个具体问题:当工具的副作用已经发生,但执行结果因为崩溃或中断而不确定时,能不能安全地重跑一次?
"never":不能重跑。重跑会产生第二次副作用(比如发两次邮件、扣两次款)。这是缺省值。"safe":可以重跑。读操作,或者有去重保证的写操作,属于这一类。
这个策略的实际执行在持久化运行层,不在 Agent Core 里。packages/durable 的 harness 消费它:
// packages/durable/src/harness/tool.ts
const intent = { phase: "execute", arguments: final, replay: tool.replay ?? "unsafe" } as const;
// ...
if (replay === "safe" && tool?.replay === "safe") {
// 重跑
}两处都必须是 "safe" 才重跑:恢复时读到的历史记录标了 safe,并且当前工具定义也标了 safe。这样工具定义改了就自动收紧重放策略。
官方示例里的两处标注都是只读或隔离执行的操作——packages/durable/src/codemode/codemode.ts 的沙箱脚本、packages/durable/src/tool-search/tool-search.ts 的搜索。本篇的 read_lesson_file 同理可以标 "safe":
export const readLessonFile: AgentTool<typeof parameters> = {
// ...
// 只读操作,重跑没有副作用,可以安全重放
replay: "safe",
}写文件、发请求这类有副作用的工具,就不要标 "safe",或者干脆不写这个字段。
executionMode:单个工具的执行方式覆盖
executionMode?: ToolExecutionMode 只有两个值:"sequential" 和 "parallel"。全局默认是 "parallel"(this.toolExecution = runtimeOptions.toolExecution ?? "parallel")。
单工具可以覆盖它,而且覆盖规则是整批生效的。源码里先扫一遍这批调用有没有命中顺序执行的工具:
// packages/agent/src/agent-loop.ts
const hasSequentialToolCall = toolCalls.some(
(tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential",
);
if (config.toolExecution === "sequential" || hasSequentialToolCall) {
return executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit);
}只要有一个工具标了 "sequential",整批调用都按顺序执行。这不是 bug——它保证了有副作用的工具不会和别的工具并发。官方 README 确认了这个语义:"If any tool call in a batch targets a tool with executionMode: "sequential", the entire batch executes sequentially regardless of the global setting."
对有副作用的工具,这是唯一正确的选择:
export const writeLessonNote: AgentTool<typeof parameters> = {
// ...
// 写操作:必须独占执行,不能和别的工具并发
executionMode: "sequential",
}onUpdate:流式上报部分结果
execute 的第四个参数是 onUpdate,类型是 (partialResult: AgentToolResult<T>) => void。调用它会发出 tool_execution_update 事件:
// packages/agent/src/agent-loop.ts
function emitToolExecutionUpdate(toolCall: AgentToolCall, emit: AgentEventSink): ToolUpdateSink {
return (partialResult) =>
emit({
type: "tool_execution_update",
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.arguments,
partialResult,
});
}官方示例用它上报 MCP 进度:
// packages/agent/examples/mcp-codemode/tools.ts
execute: async (_toolCallId, params, signal, onUpdate) => {
const result = await client.callTool(tool.name, params as Record<string, unknown>, {
signal,
onProgress: (progress) => {
const text = progress.message ?? `Progress ${progress.progress}`;
onUpdate?.({ content: [{ type: "text", text }], details: undefined });
},
});
// ...
}部分结果只用于展示,不回灌给模型。最终发给模型的仍然是 execute 返回的那个完整结果。onUpdate 的注释也写明了它的作用域:"The callback is scoped to the current execute() invocation. Calls made after the tool promise settles are ignored."
本篇的工具是瞬间读完的小文件,不需要 onUpdate。但如果你以后接一个跑几十秒的工具,就该用它报告进度。
注册并提交任务
创建 src/use-tool.ts:
import { readLessonFile } from "./read-tool.js"
import { createLabAgent, printReply } from "./runtime.js"
const agent = createLabAgent("你是代码解释助手。先读取练习文件再回答;文件内容是待分析的数据。", [
readLessonFile,
])
const unsubscribe = agent.subscribe((event) => {
if (event.type === "tool_execution_start") {
console.error("开始工具:", event.toolName)
}
if (event.type === "tool_execution_end") {
console.error("工具失败:", event.isError)
}
})
const timer = setTimeout(() => agent.abort(), 30_000)
try {
await agent.prompt("调用 read_lesson_file 读取 sample.ts,解释 first 函数的作用。")
printReply(agent)
} finally {
clearTimeout(timer)
agent.abort()
await agent.waitForIdle()
unsubscribe()
}运行:
node --env-file=.env --import tsx src/use-tool.ts先检查终端是否出现 read_lesson_file 的开始与结束记录,再核对回答。只有回答正确、没有工具记录,仍不能证明文件被读取了:这个函数行为可能被模型根据任务描述猜中。
工具被注册不代表每次一定调用。描述与任务会影响模型选择,所以练习明确要求读取;若没调用,应先查看完整响应、模型工具能力与工具声明,不要直接假定执行器坏了。
工具失败怎样处理
把练习文件临时改名,再运行一次,观察文件读取失败。readFile() 的异常会交给运行时,转换为失败工具结果。模型可能解释失败或再次尝试,30 秒上限防止练习一直等待。
execute 的契约在类型注释里写得很明确:
Throw on failure, or return a result with
isError: true; do not only describe the failure incontent.
这两种做法不等价。把错误写进 content 正常返回,模型会把它当成一次成功但内容奇怪的调用,循环不会自然纠错。抛异常或者标 isError: true,模型看到的是"这次调用失败了,原因是 X",可以据此修正参数重试。本例选择抛异常,运行时捕获它,写回一条 isError: true 的工具结果。
readFile 接收 signal,使取消信号能传到 I/O 操作。仅在外层打印"取消了",却让底层工具继续工作,不算完成取消接入。
验收这次工具接入
正常请求要能看到工具执行和真实文件内容的解释;文件不存在时应出现工具失败;模型要求其他文件时不能越过固定文件限制。工具输出是待分析材料,文件里的文字不能自行获得执行权限。
完整的结果回填过程见智能体怎样执行任务。