Learn Pi Agent
s02

自定义工具

循环与行动

加一个能力,只加一个 handler

27 LOC2 个工具defineTool 工具池
加一个能力,只加一个 handler。
机制定位
用户输入组装上下文模型推理tool_call 闸门执行工具tool_result 回传会话 / 持久化

工具注册进工具池,执行阶段多了一个动作。

s01 → s02 → s03 → ...

"加一个能力,只加一个 handler" — 循环不动,新工具注册进工具池就行。

Harness 层:行动 — 给模型一双手。


问题

pi 默认给模型四个工具:read、write、edit、bash。

这四个已经很通用,但每个领域的 harness 都需要自己的动作:查询内部工单、调用业务 API、操作数据库、写入结构化状态……这些不是「读文件」和「跑命令」能优雅表达的,而且你也不希望模型每次都用 bash 拼一个 curl。

解决方案

defineTool() 定义工具的名字、描述、参数 schema、执行函数。pi 负责把它转成模型能看到的工具定义,并在模型调用时执行它。

import { Type } from "typebox";
import { createAgentSession, defineTool } from "@earendil-works/pi-coding-agent";

const todoAdd = defineTool({
  name: "todo_add",
  label: "Add Todo",
  description: "把一条待办写入当前目录的 .todos.json。当用户要求记录任务时使用。",
  parameters: Type.Object({
    task: Type.String({ description: "待办内容" }),
    priority: Type.Optional(Type.Number({ description: "优先级 1-5,默认 3" })),
  }),
  execute: async (_toolCallId, params) => {
    // ... 真正干活
    return {
      content: [{ type: "text", text: `已添加:#${id} ${params.task}` }],
      details: { id },
    };
  },
});

const { session } = await createAgentSession({
  customTools: [todoAdd, todoList],
});

模型侧看到的是一份工具定义,内核侧维护的是一张 dispatch map:

assistant: tool_use(todo_add, {task, priority})
        │
        ▼
   pi dispatch map ──► defineTool().execute(params)
        │                        │
        │  typebox 校验参数       │  写 .todos.json
        ▼                        ▼
   tool_result ──────────► 回到模型,继续循环

就这些。主循环一行都不用改 —— 加一个工具的本质就是往 dispatch map 里注册一条;在 pi 里这张 map 由内核维护,customTools 就是注册入口。

工作原理

defineTool() 的每个字段都有明确职责:

字段给谁看作用
name模型调用时用的标识,必须唯一
description模型决定模型什么时候用它。写得越具体,命中越准
parameters模型 + 校验typebox schema,自动生成 JSON Schema 并在调用前校验
execute内核真正执行的函数,返回 tool result
label人类TUI 里显示的名字

execute 的签名:

async execute(toolCallId, params, signal, onUpdate, ctx) => ToolResult
  • params:已按 schema 校验并类型化的参数。
  • signal:AbortSignal。用户按 Esc 时触发,长任务要检查它。
  • onUpdate:流式进度回调,模型还没看到结果,用户可以先看到。
  • ctx:扩展上下文(cwd、ui、sessionManager 等)。

返回值:

{
  content: [{ type: "text", text: "给模型看的结果" }],
  details: { any: "给 UI/调用方看的结构化数据,不进入模型上下文" },
  isError?: boolean,
}

content vs details:content 会作为 tool_result 喂回模型;details 只存在于本地,用于渲染 UI 或给 SDK 消费。把冗长日志放 details,把结论放 content,能省上下文。

一个必须知道的坑:工具是并行执行的

pi 默认并发执行同一条 assistant 消息里的多个工具调用(先串行 preflight,再并发执行)。这对只读工具是加速,对 read-modify-write 是灾难:模型一次要求添加三条待办时,两个 todo_add 会同时读到空文件、各写各的,后写覆盖先写。

本章的 store.ts 用一条 promise 链把写操作串行化:

let queue: Promise<unknown> = Promise.resolve();
function serialize<T>(fn: () => Promise<T>): Promise<T> {
  const run = queue.then(fn, fn);
  queue = run.catch(() => {});
  return run;
}

这就是 harness 工程师要操心的事:模型只管发起动作,并发安全是工具实现的责任。 配套单测里有一条 并行调用不丢数据 专门守住这个回归。

还有两个同源的细节,本章代码一并处理了:

  • 读也要进锁。 todo_list 和 todo_add 同轮出现时,读路径若不加锁会读到 writeFile 写了一半的文件(后来写 prompt 模板那一章时,/review 真的把这个问题扫了出来)。
  • 写要原子。 先写临时文件再 rename,进程在写入中途被杀也不会留下半截 JSON。

变更内容

组件s01s02
工具来源内置 4 个内置 + customTools
加一个能力的成本改内核一个 defineTool()
参数校验无typebox schema,调用前自动校验
并发控制—工具自己负责(本章的串行化队列)
循环不变不变

试一下

npm run s02
npm run s02 -- "把'复习权限闸门'、'写 s03 代码'、'跑步'加入待办,优先级分别是 1、2、5,然后列出来"
cat .todos.json    # 看工具真的写了盘
npm test           # 含并行写入的回归测试

你会看到模型自动决定调用 todo_add 三次、todo_list 一次。它是自己看 description 选的,代码里没有任何 if-else 路由。

再试几个:

  • npm run s02 -- "列出全部待办" —— 只命中 todo_list
  • npm run s02 -- "今天天气怎么样" —— 两个工具都不该被调用
  • npm run s02 -- "加三条待办:A、B、C,优先级都是 1" —— 观察三个 todo_add 同轮并行,串行化队列保证不丢数据

本章要点

  • 加一个工具 = 加一个 defineTool(),注册进 customTools,循环不动。
  • description 是给模型的路由表,比代码重要。
  • signal 要检查,否则 Esc 取消不掉你的长任务。
  • 用 details 携带不喂给模型的结构化数据。
  • 同一条消息里的多个工具调用会并行执行,有副作用的工具必须自己做并发控制。

下一章

工具能被调用了,但任何工具都能被调用 —— 包括 bash 里的 rm -rf。s03:权限闸门 给动作划边界。