Files
analysis_claude_code/learn-pi-agent/s02_tool_contract/README.md
2026-06-16 00:10:35 +08:00

240 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# s02: Tool Contract — 工具先变成说明
> *把能力写成说明,再决定给谁看。*
> **Pi 边界**:工具契约边界 —— 给 provider 的工具说明,和留在 core 的执行函数,分开。
[上一节s01](../s01_minimal_agent_core/) → `s02` → [下一节s03](../s03_provider_event_stream/)
---
## 问题
s01 里provider 只看到了对话messages。但 core 手里其实还有本地能力:读一条笔记、看一眼当前时间。
怎么让 provider 知道这些能力直觉是直接把函数塞给它。但这走不通——provider 只是一个收文本、回文本的端点,它看不懂一段可执行代码,更不可能在它那边把代码跑起来。
所以得先把能力翻译成 provider 能读的东西:一份**说明**。
s02 只做这一件事:把本地能力变成说明,交给 provider。本节还不执行任何工具。
---
## 解决方案
一个工具拆成两层:
```text
Tool
spec → 进 ProviderInput给 provider 看
handler → 留在本地 ToolRegistry
```
provider 收到的永远是 `spec`(说明),`handler`(执行函数)从不出 core。
这里有个故意的分隔:**provider 看得见的工具集合,和 core 实际跑得了的工具集合,不一样。** provider 只看到说明,看不到、也碰不到执行函数。这条分隔从 s02 立起来,后面所有和工具相关的机制都建立在它之上。
---
## 工作原理
**先定义说明。** 一份工具说明要回答三件事:叫什么名字、干什么用、要什么参数。
```ts
export type ToolSpec = {
name: string;
description: string;
input: Record<string, string>;
};
```
**再定义本地执行。** handler 是一段普通函数,待在 core 这边provider 看不见它。
```ts
export type ToolHandler = (input: Record<string, string>) => string;
```
**把两层合起来是一个完整工具。** spec 和 handler 在 Tool 里配对,但只有 spec 会离开 core。
```ts
export type Tool = {
spec: ToolSpec;
handler: ToolHandler;
};
```
**用一个登记表把它们收起来。** ToolRegistry 持有完整工具,但它对外只交出说明——`getSpecs()` 返回 spec不带 handler。
```ts
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` 接收 registry`registry.getSpecs()` 放进去。
```ts
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 这边跑。
---
## 试一下
运行:
```sh
npm run s02
```
输出类似:
```text
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` |
| 新增类 | — | `ToolRegistry``register` / `getSpecs` |
| `buildProviderInput` | `(state)` | `(state, registry)` |
| `runOneTurn` | `(state, provider, userInput)` | `(state, provider, registry, userInput)` |
**焊接点**`buildProviderInput``registry.getSpecs()` 塞进 `ProviderInput.tools`handler 留在 registry绝不进 ProviderInput。
---
## 接下来
现在 provider 能看到工具说明了但它还是一次性吐出整段回复core 得等到最后才知道它说了什么。
下一节会改变 provider 返回结果的方式——不再一次性返回,而是一段一段地往外送。
进入下一节:[s03](../s03_provider_event_stream/)。
---
<details>
<summary>Pi 源码溯源:工具的双层定义</summary>
教学版用 `Tool = { spec, handler }` 一层搞定。Pi 把工具拆成**两层类型**,分属两个 package。
### 源码在哪
- `packages/ai/src/types.ts:338``Tool`(给 provider 看的那层)
- `packages/agent/src/types.ts:361``AgentTool`(本地执行的那层)
- `packages/agent/src/agent-loop.ts:548``prepareToolCallArguments`(参数预处理)
### 两层工具
**AI 层的 `Tool`**`ai` 包)只描述能力,不含任何可执行代码——它会被序列化发给 provider
```ts
interface Tool<TParameters extends TSchema = TSchema> {
name: string;
description: string;
parameters: TParameters; // TypeBox schema给 LLM 看的参数结构
}
```
**Agent 层的 `AgentTool`**`agent` 包)继承 `Tool`,再加执行相关的东西:
```ts
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.input``Record<string, string>`字符串字典。Pi 用 **TypeBox**`parameters: TSchema`)——一种运行时可校验的 JSON Schema 类型系统:
- 能表达嵌套、枚举、可选、范围(字符串字典做不到)。
- provider 收到的是标准 JSON Schema跨厂商通用。
- `prepareArguments` 拿到的参数能被 schema 校验和转换。
教学版不引入 schema 库代价是参数描述很弱s04 的 ToolCall 也只能带字符串)。
### prepareArguments参数预处理钩子
教学版 handler 直接吃原始 input。Pi 的 `AgentTool` 多了一个 `prepareArguments``agent-loop.ts:548`
```ts
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 把这条边界坐实成两个 package`ai` 层的 `Tool`schema、给 LLM`agent` 层的 `AgentTool`execute、本地中间隔着参数预处理、中断、进度上报。教学版压成一层把这条边界先立起来。
</details>