跳到主内容
创建时间:最后更新:作者:白川

第一个自定义工具

为一个只读动作定义输入与结果,把模型意图挡在参数校验之后,让失败成为异常而不是一段文本。

第一个自定义工具

一个只会调 LLM 的程序,问不了你的数据库。这个缺口用工具补——模型提出结构化调用请求,程序校验参数后执行,再把结果送回上下文。

写工具最容易出的错不是跑不起来,而是语义跑偏了:模型传了个你没预料到的路径,你返回一段"参数不合法"的文本当正常结果。模型把它当成成功输出继续推理,工具于是变成了一个"说谎的函数"。

Pi 的工具契约里有一条规则来防这件事:失败必须抛异常,不要把错误当正常内容返回。

这篇讲清工具契约长什么样、失败时结果怎么回填、以及模型意图如何被挡住。读完你能为一个只读动作写出完整定义,把参数校验与执行钩子的确切顺序讲明白,并知道哪些事必须由宿主兜底。

读它需要会用 TypeScript,并且了解 TypeBox 这类 JSON Schema 描述方式。你还要接受一个前提:模型看到的只有工具的 name 和 description,你的实现代码它一行都看不到。

最终要跑通什么

  1. 定义一个只读工具 list_files,输入是目录路径,输出是文件清单。
  2. 参数用 TypeBox schema 声明,模型传错类型时工具不会被执行。
  3. 目录不存在时抛异常,让模型收到 isError 的工具结果,而不是伪装成成功的文本。
  4. 用 onUpdate 把进度流式报出去。
  5. 说清 beforeToolCall 怎么在执行前把这次调用挡下来。

第 3 点是完成标准。失败的处理方式决定了这个工具是可信赖的还是会骗模型的。

AgentTool 的结构

工具定义是一个对象,字段不多,但每一个都有明确职责。

字段类型职责
name
string
模型在工具调用里用的名字,必须唯一
label
string
界面显示用的可读标签
description
string
给模型看的说明,它据此决定何时调用
parameters
TypeBox schema
参数的形状与约束
execute
function
真正干活的地方
executionMode
"parallel" | "sequential"
可选,覆盖全局执行模式
prepareArguments
function
可选,在 schema 校验前把原始参数整理成目标形状
outputSchema
TypeBox schema
可选,structuredContent 的 JSON Schema

description 是最重要的字段。模型看不到你的代码,只看得到 name 和 description。写"读取文件"和写"读取指定目录下的文件清单",模型调用它的准确率差别很大。

参数 schema:用 TypeBox 声明

参数用 TypeBox 描述。pi-ai 用 TypeBox 自带的校验器做校验和值转换,schema 可序列化成纯 JSON。

typescript
// 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 的四个参数

typescript
// 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: {} };
};
参数类型用途
toolCallId
string
本次调用的 ID,用于把结果关联回这次调用
params
已校验并转换过的参数
不是模型原始发来的 JSON
signal
AbortSignal | undefined
取消信号,用户中断或会话中止时触发
onUpdate
回调 | undefined
上报中间进度

params 已经是校验后的值,这是工具契约的核心保证。校验阶段同时做类型转换:能转成声明类型的值会被转过去(字符串形式的数字会变成数字),转不过去的值进入错误分支,execute 不会被调用。

signal 必须真的被使用。忽略它的长任务会在用户取消后继续跑完,把结果写进一个没人关心的会话。

返回值:content 和 details

返回值有两个部分,职责完全不同。

字段类型给谁看
content
(TextContent | ImageContent)[]
模型。下一次请求会读到的内容
details
任意
程序。日志、界面渲染、结构化记录
typescript
// 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 文档的原话是:工具失败时抛异常,不要把错误消息当内容返回。

typescript
// 反例:错误被伪装成成功输出
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 上报中间结果。回调的参数是一个和最终返回值同构的部分结果。

typescript
// onUpdate 报的是中间进度,参数形状和最终返回值同构,但只活到 promise 落地为止
onUpdate?.({
	content: [{ type: "text", text: "正在扫描 src/ ……" }],
	details: { phase: "scan", dir: "src" },
});

这个回调的生存期限定在当前 execute() 调用内,promise 落地后再调用不会产生效果。对应的会话事件是 tool_execution_update,携带 partialResult。

executionMode: "sequential":强制整批串行

默认的工具执行模式是 parallel——一批调用先串行预检,再并发执行被允许的。

typescript
// 单个工具覆盖全局执行模式:标了它,这一批工具调用整批串行
executionMode: "sequential",

给单个工具标上 "sequential",这一批调用就会整个串行执行。官方文档明确写了:只要一批调用里有任何一个指向标了 executionMode: "sequential" 的工具,整批都串行,不管全局设置是什么。

什么时候需要它?工具之间有顺序依赖、或者会改动同一份共享状态的时候。只读的文件列表工具不需要——它天然可以并发。

模型意图如何被挡住

这是本篇的完成标准。一个工具调用从产生到执行,中间有四道关卡,顺序是确定的:

text
# 一个工具调用从产生到落地的四道关卡,箭头方向就是实际执行顺序
tool_execution_start
  → prepareArguments(可选的参数预处理)
  → 参数 schema 校验与转换
  → beforeToolCall 钩子
  → execute
  → afterToolCall 钩子
  → tool_execution_end
关卡位置失败时的结果
参数预处理
校验之前
isError 工具结果,execute 不执行
参数校验
校验时
isError 工具结果,execute 不执行
beforeToolCall
校验之后、执行之前
isError 工具结果,execute 不执行
execute
最后
抛出的异常转成 isError 工具结果

注意 tool_execution_start 是在参数校验之前发出的。界面上看到"工具开始执行",不代表它真会执行——被校验或钩子拦下时,工具一次都没跑。

beforeToolCall:执行前的最后一道门

beforeToolCall 在参数校验之后、执行之前运行,可以返回 { block: true, reason } 把这次调用挡下来。

typescript
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 事件提供,语义完全一致。

一个完整的只读工具

把上面这些合起来。这个工具只做一件事:列出目录下的文件。

typescript
// 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 传:

typescript
// 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() 是它的类型辅助。

谁负责什么

职责由谁承担说明
定义参数形状与约束
宿主
TypeBox schema,模型和 Pi 都看它
判断何时该调用这个工具
模型
依据 name 和 description
校验参数、转换类型
Pi
通过 schema,失败则不执行
执行前的准入判断
宿主
beforeToolCall 或扩展的 tool_call
真正干活
宿主
execute 里的代码
失败如何变成模型能理解的信号
Pi
异常转成 isError 的工具结果
决定哪些内容进模型上下文
宿主
content 与 details 的分工

你现在能做什么

  • 写一个只读工具,让模型能列出目录内容,并用 Static<typeof parameters> 让参数类型和 schema 对齐。
  • 让工具在路径越界时抛异常,验证模型收到的是 isError: true 的结果而不是一段伪装成功的文本。
  • 给工具加一条 beforeToolCall 守卫,把明显越界的参数挡在执行之前,并给模型一句可照做的修正建议。
  • 在长循环里检查 signal?.aborted,验证取消能真正中断工具。
  • 把工具装进 createAgentSession({ customTools }) 跑一次,再确认它的 promptSnippet 决定它是否出现在系统提示里。

排障速查

现象检查点处理
模型从来不调这个工具
description
它是模型唯一能看到的说明。写清"什么时候用",别只写"做什么"
模型传了参数但工具没跑
参数校验
schema 没通过,execute 不会执行。用有没有 tool_execution_end 来区分
界面显示"工具开始"但没结果
事件顺序
tool_execution_start 在校验之前发出,被挡下的调用不会有成功的 tool_execution_end
模型把失败当成成功
返回值
别把错误写进 content 返回。抛异常,让它成为 isError 的工具结果
工具结果太长,撑爆上下文
content
只放模型接下来需要的信息,其余放 details
用户取消了,工具还在跑
signal
在长循环里检查 signal?.aborted
两个工具互相干扰
executionMode
有顺序依赖或共享状态时标 "sequential",整批会串行
工具参数名写错
schema 与代码
字段名必须与 schema 一致;用 Static<typeof parameters> 让类型对齐
工具在界面上没有名字
label
label 界面显示用,name 模型调用用,两个都要填
自定义工具没被模型调用
工具白名单
tools 白名单会连自定义工具一起过滤,确认名字在名单里

参考资料