第一个自定义工具
为一个只读动作定义输入与结果,把模型意图挡在参数校验之后,让失败成为异常而不是一段文本。
第一个自定义工具
一个只会调 LLM 的程序,问不了你的数据库。这个缺口用工具补——模型提出结构化调用请求,程序校验参数后执行,再把结果送回上下文。
写工具最容易出的错不是跑不起来,而是语义跑偏了:模型传了个你没预料到的路径,你返回一段"参数不合法"的文本当正常结果。模型把它当成成功输出继续推理,工具于是变成了一个"说谎的函数"。
Pi 的工具契约里有一条规则来防这件事:失败必须抛异常,不要把错误当正常内容返回。
这篇讲清工具契约长什么样、失败时结果怎么回填、以及模型意图如何被挡住。读完你能为一个只读动作写出完整定义,把参数校验与执行钩子的确切顺序讲明白,并知道哪些事必须由宿主兜底。
读它需要会用 TypeScript,并且了解 TypeBox 这类 JSON Schema 描述方式。你还要接受一个前提:模型看到的只有工具的 name 和 description,你的实现代码它一行都看不到。
最终要跑通什么
- 定义一个只读工具
list_files,输入是目录路径,输出是文件清单。 - 参数用 TypeBox schema 声明,模型传错类型时工具不会被执行。
- 目录不存在时抛异常,让模型收到
isError的工具结果,而不是伪装成成功的文本。 - 用
onUpdate把进度流式报出去。 - 说清
beforeToolCall怎么在执行前把这次调用挡下来。
第 3 点是完成标准。失败的处理方式决定了这个工具是可信赖的还是会骗模型的。
AgentTool 的结构
工具定义是一个对象,字段不多,但每一个都有明确职责。
description 是最重要的字段。模型看不到你的代码,只看得到 name 和 description。写"读取文件"和写"读取指定目录下的文件清单",模型调用它的准确率差别很大。
参数 schema:用 TypeBox 声明
参数用 TypeBox 描述。pi-ai 用 TypeBox 自带的校验器做校验和值转换,schema 可序列化成纯 JSON。
// schemas.ts
import { Type } from "typebox";
// 参数 schema 同时给模型和运行时看:模型据此填参数,运行时据此校验和转换
export const listFilesParameters = Type.Object({
dir: Type.String({
description: "要列出的目录,相对会话工作目录。空字符串表示工作目录本身。",
}),
// Optional 在 schema 层就告诉模型这个参数可以不传
maxEntries: Type.Optional(
Type.Number({
description: "最多返回多少条,默认 200。",
minimum: 1,
maximum: 1000,
}),
),
});Type 有两个来源:从 typebox 直接引入,或从 @earendil-works/pi-ai 引入——后者把 Type、Static、TSchema 重新导出了一遍。项目没直接依赖 typebox 时用后者。
可选参数的写法是 Type.Optional(...)。它在 schema 层就告诉模型"这个参数可以不传",比在代码里判断 undefined 有效得多。
execute 的四个参数
// execute 的四个参数都由运行时注入:params 已经过 schema 校验,不是模型原始发来的 JSON
const execute = async (
toolCallId: string,
params: { dir: string },
signal: AbortSignal | undefined,
onUpdate: ((partial: { content: { type: "text"; text: string }[]; details: unknown }) => void) | undefined,
) => {
// 用 signal 判断是否被取消,用 onUpdate 报进度
// 返回 { content, details }
return { content: [{ type: "text", text: "..." }], details: {} };
};params 已经是校验后的值,这是工具契约的核心保证。校验阶段同时做类型转换:能转成声明类型的值会被转过去(字符串形式的数字会变成数字),转不过去的值进入错误分支,execute 不会被调用。
signal 必须真的被使用。忽略它的长任务会在用户取消后继续跑完,把结果写进一个没人关心的会话。
返回值:content 和 details
返回值有两个部分,职责完全不同。
// content 给模型读,details 给程序用:两者不要互相串味
return {
content: [{ type: "text", text: "src/index.ts\nsrc/util.ts\nREADME.md" }],
details: { dir: "src", count: 3, truncated: false },
};content 里的文本会原样进入模型的上下文,所以它只放模型接下来需要的信息。details 放你想记录但不打算让模型读的东西——路径、耗时、是否被截断。需要给程序一个结构化结果时,可以声明 outputSchema 并返回 structuredContent,它同样不发给模型。
失败必须抛异常
这是本篇最重要的一条规则。Agent Core 文档的原话是:工具失败时抛异常,不要把错误消息当内容返回。
// 反例:错误被伪装成成功输出
const listFilesParams = Type.Object({ dir: Type.String() });
const badTool: AgentTool<typeof listFilesParams> = {
name: "list_files",
label: "List Files",
description: "列出目录下的文件",
parameters: listFilesParams,
execute: async (toolCallId, params) => {
// 错在这里:把失败写进 content 返回,模型会当成一次成功调用
if (!existsSync(params.dir)) {
return {
content: [{ type: "text", text: `目录不存在:${params.dir}` }],
details: { ok: false },
};
}
// 成功路径也没有任何错误标记:整个返回值里找不到「这次调用失败了」的信号
return { content: [], details: {} };
},
};这个反例的问题不在于它"不够严谨",而在于它改变了失败的语义。抛出的异常会被 agent 捕获,作为带 isError: true 的工具结果报给模型,模型知道这次调用失败了,可以换参数重试。而返回的文本被当成成功结果,模型看到"目录不存在:xxx"可能以为目录是空的,转去汇报"这个项目没有文件"。
类型里还有另一个选项:AgentToolResult 允许返回 isError: true 而不抛异常。区别在于——模型看到的 content 一样是错误结果,但 details 和 structuredContent 会保留给界面和程序。既想向模型报告失败、又想给界面留结构化诊断时用它;其余情况一律抛异常。
onUpdate:把进度报出去
工具执行期间可以调用 onUpdate 上报中间结果。回调的参数是一个和最终返回值同构的部分结果。
// onUpdate 报的是中间进度,参数形状和最终返回值同构,但只活到 promise 落地为止
onUpdate?.({
content: [{ type: "text", text: "正在扫描 src/ ……" }],
details: { phase: "scan", dir: "src" },
});这个回调的生存期限定在当前 execute() 调用内,promise 落地后再调用不会产生效果。对应的会话事件是 tool_execution_update,携带 partialResult。
executionMode: "sequential":强制整批串行
默认的工具执行模式是 parallel——一批调用先串行预检,再并发执行被允许的。
// 单个工具覆盖全局执行模式:标了它,这一批工具调用整批串行
executionMode: "sequential",给单个工具标上 "sequential",这一批调用就会整个串行执行。官方文档明确写了:只要一批调用里有任何一个指向标了 executionMode: "sequential" 的工具,整批都串行,不管全局设置是什么。
什么时候需要它?工具之间有顺序依赖、或者会改动同一份共享状态的时候。只读的文件列表工具不需要——它天然可以并发。
模型意图如何被挡住
这是本篇的完成标准。一个工具调用从产生到执行,中间有四道关卡,顺序是确定的:
# 一个工具调用从产生到落地的四道关卡,箭头方向就是实际执行顺序
tool_execution_start
→ prepareArguments(可选的参数预处理)
→ 参数 schema 校验与转换
→ beforeToolCall 钩子
→ execute
→ afterToolCall 钩子
→ tool_execution_end注意 tool_execution_start 是在参数校验之前发出的。界面上看到"工具开始执行",不代表它真会执行——被校验或钩子拦下时,工具一次都没跑。
beforeToolCall:执行前的最后一道门
beforeToolCall 在参数校验之后、执行之前运行,可以返回 { block: true, reason } 把这次调用挡下来。
const agent = new Agent({
initialState: { systemPrompt: "你是一个文件助手。", model },
streamFn: models.streamSimple.bind(models),
// beforeToolCall 在参数校验之后、执行之前跑:这里挡住调用,execute 一次都不会跑
beforeToolCall: async ({ toolCall, args }) => {
if (toolCall.name === "list_files") {
const dir = String((args as { dir?: string })?.dir ?? "");
// 越出工作目录的路径一律拒绝
if (dir.includes("..") || dir.startsWith("/")) {
return {
block: true,
// reason 会成为给模型看的错误文本,要给出可照做的修正方向
reason: "只能列出工作目录内的相对路径,请去掉 .. 和开头的 /",
};
}
}
// 返回 undefined 表示放行
return undefined;
},
});reason 会成为给模型看的那段错误文本——要写清楚为什么被拒绝、下一步该怎么做,而不是只写"不允许"。上面那句给了模型可以直接照做的修正方向。
注意这里的检查是双层的:beforeToolCall 挡住明显的越界写法,工具内部再按解析后的绝对路径校验一次。前者给出好理解的拒绝理由,后者保证边界不被绕过。
钩子还可以返回 terminate: true,提示 agent 在这批工具之后跳过自动的后续模型调用。它只在这批里每个最终结果都设了 terminate 时生效,混合批次照常继续。在 Coding Agent 里,同一道门由扩展的 tool_call 事件提供,语义完全一致。
一个完整的只读工具
把上面这些合起来。这个工具只做一件事:列出目录下的文件。
// tools/list-files.ts
import { readdir } from "node:fs/promises";
import { join, relative, resolve, sep } from "node:path";
import { Type, type Static } from "typebox";
import type { AgentTool } from "@earendil-works/pi-agent-core";
const parameters = Type.Object({
dir: Type.String({ description: "要列出的目录,相对工作目录。空字符串表示工作目录本身。" }),
maxEntries: Type.Optional(
Type.Number({ description: "最多返回多少条,默认 200。", minimum: 1, maximum: 1000 }),
),
});
type Params = Static<typeof parameters>;
export function createListFilesTool(cwd: string): AgentTool<typeof parameters> {
const base = resolve(cwd);
return {
name: "list_files",
label: "List Files",
description:
"列出指定目录下的文件条目,返回相对路径。只读,不修改任何文件。" +
"当用户问某个目录里有哪些文件时使用。",
parameters,
executionMode: "sequential",
execute: async (toolCallId, params: Params, signal, onUpdate) => {
const maxEntries = params.maxEntries ?? 200;
const target = resolve(base, params.dir || ".");
// resolve() 只做规范化,不做限制:../ 依然能逃出工作目录,必须自己检查
if (target !== base && !target.startsWith(base + sep)) {
throw new Error(`路径越出工作目录:${params.dir}`);
}
onUpdate?.({
content: [{ type: "text", text: `正在列出 ${params.dir || "."} …` }],
details: { phase: "scan", dir: params.dir },
});
let entries;
try {
entries = await readdir(target, { withFileTypes: true });
} catch (error) {
// 失败必须抛出:模型会收到 isError 的工具结果,可以据此调整
throw new Error(`无法列出目录 ${params.dir || "."}:${(error as Error).message}`);
}
const lines: string[] = [];
for (const entry of entries) {
if (signal?.aborted) throw new Error("已取消");
if (lines.length >= maxEntries) break;
const relativePath = relative(base, join(target, entry.name));
lines.push(entry.isDirectory() ? `${relativePath}/` : relativePath);
}
const truncated = entries.length > lines.length;
return {
content: [{ type: "text", text: lines.join("\n") || "(空目录)" }],
details: { dir: params.dir, count: lines.length, truncated },
};
},
};
}几个细节值得说:
resolve()只做规范化,不做限制。resolve(base, "../../etc")会得到一个正常的绝对路径,..依然能逃出工作目录。边界要自己划——上面用startsWith(base + sep)检查,逃逸就抛异常。- 返回的路径相对工作目录根,不是相对被列出的目录。 模型拿到
src/index.ts比拿到index.ts更有用——后者丢掉了"在哪个目录下"。 signal?.aborted在循环里检查。 目录很大时,用户可能在中途取消。- 截断信息只放在
details里。 实际场景建议把截断提示也写进文本。 executionMode: "sequential"在只读工具上并不必要,保留它是为了演示字段位置。
装进会话
Agent Core 里直接赋值 agent.state.tools = [createListFilesTool(process.cwd())];。SDK 里通过 customTools 传:
// run.ts
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
import { createListFilesTool } from "./tools/list-files.js";
const { session } = await createAgentSession({
cwd: process.cwd(),
// customTools 是追加,不是替换:内置工具照样在
customTools: [createListFilesTool(process.cwd())],
// 用内存会话:练习不写磁盘文件
sessionManager: SessionManager.inMemory(),
});
try {
await session.prompt("这个项目里 src 目录下有哪些文件?");
console.log(session.getLastAssistantText());
} finally {
// 必须释放:否则扩展的清理处理器不会运行
session.dispose();
}customTools 是在内置工具之外追加的工具。想限制模型能调用的集合,用 tools(白名单)或 excludeTools(黑名单)——这两个管的是"启用哪些",customTools 管的是"注册了哪些"。
注意:tools 白名单会连自定义工具一起过滤。白名单里没有你的工具名,它就不会被声明给模型。
这里有一个类型细节。SDK 的 customTools 声明的类型是 ToolDefinition[],它的 execute 比核心契约多一个末位参数 ctx。函数参数少的时候可以赋值给参数多的类型,所以上面这个四参数的 AgentTool 可以直接传进去,多出来的 ctx 被忽略。需要扩展上下文(比如在工具里弹确认框)时,把签名写成五参数并接住 ctx 即可。
另外注意 promptSnippet:没有它,自定义工具保持激活状态,但不出现在默认系统提示的 "Available tools" 段落里。模型的工具调用声明仍包含它,所以照样能调——只是不会从提示词里学到"有这个工具"。
需要扩展运行时提供的能力(界面交互、执行其他工具)时,用扩展的 pi.registerTool() 注册,defineTool() 是它的类型辅助。
谁负责什么
你现在能做什么
- 写一个只读工具,让模型能列出目录内容,并用
Static<typeof parameters>让参数类型和 schema 对齐。 - 让工具在路径越界时抛异常,验证模型收到的是
isError: true的结果而不是一段伪装成功的文本。 - 给工具加一条
beforeToolCall守卫,把明显越界的参数挡在执行之前,并给模型一句可照做的修正建议。 - 在长循环里检查
signal?.aborted,验证取消能真正中断工具。 - 把工具装进
createAgentSession({ customTools })跑一次,再确认它的promptSnippet决定它是否出现在系统提示里。