Files
analysis_claude_code/learn-pi-agent/s02_tool_contract

s02: Tool Contract — 工具先变成说明

把能力写成说明,再决定给谁看。 Pi 边界:工具契约边界 —— 给 provider 的工具说明,和留在 core 的执行函数,分开。

上一节s01s02下一节s03


问题

s01 里provider 只看到了对话messages。但 core 手里其实还有本地能力:读一条笔记、看一眼当前时间。

怎么让 provider 知道这些能力直觉是直接把函数塞给它。但这走不通——provider 只是一个收文本、回文本的端点,它看不懂一段可执行代码,更不可能在它那边把代码跑起来。

所以得先把能力翻译成 provider 能读的东西:一份说明

s02 只做这一件事:把本地能力变成说明,交给 provider。本节还不执行任何工具。


解决方案

一个工具拆成两层:

Tool
  spec     →  进 ProviderInput给 provider 看
  handler  →  留在本地 ToolRegistry

provider 收到的永远是 spec(说明),handler(执行函数)从不出 core。

这里有个故意的分隔:provider 看得见的工具集合,和 core 实际跑得了的工具集合,不一样。 provider 只看到说明,看不到、也碰不到执行函数。这条分隔从 s02 立起来,后面所有和工具相关的机制都建立在它之上。


工作原理

先定义说明。 一份工具说明要回答三件事:叫什么名字、干什么用、要什么参数。

export type ToolSpec = {
  name: string;
  description: string;
  input: Record<string, string>;
};

再定义本地执行。 handler 是一段普通函数,待在 core 这边provider 看不见它。

export type ToolHandler = (input: Record<string, string>) => string;

把两层合起来是一个完整工具。 spec 和 handler 在 Tool 里配对,但只有 spec 会离开 core。

export type Tool = {
  spec: ToolSpec;
  handler: ToolHandler;
};

用一个登记表把它们收起来。 ToolRegistry 持有完整工具,但它对外只交出说明——getSpecs() 返回 spec不带 handler。

export class ToolRegistry {
  private tools = new Map<string, Tool>();

  register(tool: Tool): void {
    this.tools.set(tool.spec.name, tool);
  }

  getSpecs(): ToolSpec[] {
    return [...this.tools.values()].map((tool) => tool.spec);
  }
}

最后把说明塞进 provider 输入。 s01 的 ProviderInput 只有 messages现在多一个 tools。buildProviderInput 接收 registryregistry.getSpecs() 放进去。

export type ProviderInput = {
  messages: ProviderMessage[];
  tools: ToolSpec[];
};

export function buildProviderInput(
  state: AgentState,
  registry: ToolRegistry,
): ProviderInput {
  return {
    messages: state.messages.map((message) => ({
      role: message.role,
      content: message.content,
    })),
    tools: registry.getSpecs(),
  };
}

这一节真正建立的不是某个函数,而是说明和执行分开provider 拿到的永远是说明handler 永远不出 core。后面 s04 会让 provider 真的"调用"工具但即便到那时provider 发出的也只是一个调用请求handler 仍然在 core 这边跑。


试一下

运行:

npm run s02

输出类似:

s02: Tool Contract

[tools registered]
read_note: 读取一条笔记
current_time: 返回一个固定的演示时间

[provider input]
messages: 1
tools: 2
- read_note: 读取一条笔记
- current_time: 返回一个固定的演示时间

[assistant]
content: 我看到 2 个工具read_note, current_time
stopReason: stop

观察重点:[provider input] 的 tools 里只有说明name / description没有任何执行函数[tools registered] 和 provider 看到的是同一份说明。


接入主线

s02 在 s01 上累积。相对 s01 的变更:

组件 s01 s02
ProviderInput { messages } { messages, tools }R1 只增)
新增类型 ToolSpec / ToolHandler / Tool
新增类 ToolRegistryregister / getSpecs
buildProviderInput (state) (state, registry)
runOneTurn (state, provider, userInput) (state, provider, registry, userInput)

焊接点buildProviderInputregistry.getSpecs() 塞进 ProviderInput.toolshandler 留在 registry绝不进 ProviderInput。


接下来

现在 provider 能看到工具说明了但它还是一次性吐出整段回复core 得等到最后才知道它说了什么。

下一节会改变 provider 返回结果的方式——不再一次性返回,而是一段一段地往外送。

进入下一节:s03


Pi 源码溯源:工具的双层定义

教学版用 Tool = { spec, handler } 一层搞定。Pi 把工具拆成两层类型,分属两个 package。

源码在哪

  • packages/ai/src/types.ts:338Tool(给 provider 看的那层)
  • packages/agent/src/types.ts:361AgentTool(本地执行的那层)
  • packages/agent/src/agent-loop.ts:548prepareToolCallArguments(参数预处理)

两层工具

AI 层的 Toolai 包)只描述能力,不含任何可执行代码——它会被序列化发给 provider

interface Tool<TParameters extends TSchema = TSchema> {
  name: string;
  description: string;
  parameters: TParameters;   // TypeBox schema给 LLM 看的参数结构
}

Agent 层的 AgentToolagent 包)继承 Tool,再加执行相关的东西:

interface AgentTool<TParameters, TDetails> extends Tool<TParameters> {
  label: string;             // UI 显示标签
  prepareArguments?: (args: unknown) => Static<TParameters>;  // 参数预处理
  execute: (toolCallId, params, signal?, onUpdate?) => Promise<AgentToolResult<TDetails>>;
  executionMode?: "sequential" | "parallel";   // 单工具覆盖执行模式
}

教学版的 Tool = { spec, handler } 把这两层压成一层。Pi 之所以分两个 package是因为 ai 层只关心"怎么跟 LLM 说话"schema、序列化agent 层才关心"怎么在本地执行"。

参数用 TypeBox schema不是简单对象

教学版 ToolSpec.inputRecord<string, string>字符串字典。Pi 用 TypeBoxparameters: TSchema)——一种运行时可校验的 JSON Schema 类型系统:

  • 能表达嵌套、枚举、可选、范围(字符串字典做不到)。
  • provider 收到的是标准 JSON Schema跨厂商通用。
  • prepareArguments 拿到的参数能被 schema 校验和转换。

教学版不引入 schema 库代价是参数描述很弱s04 的 ToolCall 也只能带字符串)。

prepareArguments参数预处理钩子

教学版 handler 直接吃原始 input。Pi 的 AgentTool 多了一个 prepareArgumentsagent-loop.ts:548

function prepareToolCallArguments(tool, toolCall) {
  if (!tool.prepareArguments) return toolCall;
  const prepared = tool.prepareArguments(toolCall.arguments);
  if (prepared === toolCall.arguments) return toolCall;
  return { ...toolCall, arguments: prepared };
}

provider 给的参数可能粗糙或带默认值,prepareArguments 在执行前统一加工——教学版没有的一层"参数防腐"。

execute 带 AbortSignal 和 onUpdate

教学版的 ToolHandler 是同步的 (input) => string。Pi 的 execute 多两个参数:

  • signal: AbortSignal:用户中断时能响应(呼应 s01 的 AbortController
  • onUpdate执行中往外推流式进度partialResultUI 能实时显示"工具跑到哪了"。

教学版的工具是"调一下、拿个字符串"Pi 的工具是"一个能被中断、能报进度的小任务"。

一句话

Tool = { spec, handler } 立的是"说明和执行分开"。Pi 把这条边界坐实成两个 packageai 层的 Toolschema、给 LLMagent 层的 AgentToolexecute、本地中间隔着参数预处理、中断、进度上报。教学版压成一层把这条边界先立起来。