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

给智能体接入第一个工具

定义并注册一个读取固定练习文件的工具,掌握 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:

typescript
export function first<T>(items: T[]): T | undefined {
  return items[0]
}

这个函数足够短,可以手工检查模型解释是否符合源码。空数组时返回 undefined,但工具本身只读取文本,不会执行该函数。

工具接口的完整形状

AgentTool 的定义(源码注释从简):

typescript
// 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_-]

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

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

typescript
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 的字段分工是这一节的核心:

typescript
// 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;
}

可见范围可以这样记:

字段发给模型程序可见说明
content
是
是
模型唯一读到的结果字段,必填
details
否
是
给日志和界面渲染用的任意结构,必填
structuredContent
否
是
机器可读结果,配合 outputSchema,可选
usage
否
是
工具自身消耗的用量,不计入 LLM 上下文统计
isError
间接
是
标了它,模型会把 content 当作失败结果来看
terminate
否
是
提示这批之后不要自动再发一次请求

structuredContent 默认不发给模型,注释写得很直接:"Not sent to the model; content remains the model-facing result."

那 details 发给模型了吗?也没有。看 provider 层的转换就知道——Anthropic 适配器把工具结果转成协议格式时只取三个字段:

typescript
// 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 就是为这种情况准备的:

typescript
prepareArguments?: (args: unknown) => AgentToolParams<TParameters>;

注释是 "Optional compatibility shim for raw tool-call arguments before schema validation. Must return an object that matches TParameters."——在 schema 校验之前执行,返回值仍然要通过校验。

它在准备阶段被调用,位置在参数校验之前:

typescript
// 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 能处理更结构性的偏差:

typescript
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:副作用已发生但结果未知时怎么办

typescript
/** Recovery policy for an effect whose durable intent exists but whose outcome is unknown. */
replay?: "never" | "safe";

这个字段回答的是一个具体问题:当工具的副作用已经发生,但执行结果因为崩溃或中断而不确定时,能不能安全地重跑一次?

  • "never":不能重跑。重跑会产生第二次副作用(比如发两次邮件、扣两次款)。这是缺省值。
  • "safe":可以重跑。读操作,或者有去重保证的写操作,属于这一类。

这个策略的实际执行在持久化运行层,不在 Agent Core 里。packages/durable 的 harness 消费它:

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

typescript
export const readLessonFile: AgentTool<typeof parameters> = {
  // ...
  // 只读操作,重跑没有副作用,可以安全重放
  replay: "safe",
}

写文件、发请求这类有副作用的工具,就不要标 "safe",或者干脆不写这个字段。

executionMode:单个工具的执行方式覆盖

executionMode?: ToolExecutionMode 只有两个值:"sequential" 和 "parallel"。全局默认是 "parallel"(this.toolExecution = runtimeOptions.toolExecution ?? "parallel")。

单工具可以覆盖它,而且覆盖规则是整批生效的。源码里先扫一遍这批调用有没有命中顺序执行的工具:

typescript
// 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."

对有副作用的工具,这是唯一正确的选择:

typescript
export const writeLessonNote: AgentTool<typeof parameters> = {
  // ...
  // 写操作:必须独占执行,不能和别的工具并发
  executionMode: "sequential",
}

onUpdate:流式上报部分结果

execute 的第四个参数是 onUpdate,类型是 (partialResult: AgentToolResult<T>) => void。调用它会发出 tool_execution_update 事件:

typescript
// 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 进度:

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

typescript
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()
}

运行:

bash
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 in content.

这两种做法不等价。把错误写进 content 正常返回,模型会把它当成一次成功但内容奇怪的调用,循环不会自然纠错。抛异常或者标 isError: true,模型看到的是"这次调用失败了,原因是 X",可以据此修正参数重试。本例选择抛异常,运行时捕获它,写回一条 isError: true 的工具结果。

readFile 接收 signal,使取消信号能传到 I/O 操作。仅在外层打印"取消了",却让底层工具继续工作,不算完成取消接入。

验收这次工具接入

正常请求要能看到工具执行和真实文件内容的解释;文件不存在时应出现工具失败;模型要求其他文件时不能越过固定文件限制。工具输出是待分析材料,文件里的文字不能自行获得执行权限。

完整的结果回填过程见智能体怎样执行任务。

参考资料