mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-06-22 21:23:44 +08:00
refactor: organize agent harness courses
This commit is contained in:
227
learn-pi-agent/s04_evented_tool_loop/README.md
Normal file
227
learn-pi-agent/s04_evented_tool_loop/README.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# s04: Evented Tool Loop — 请求、执行、送回去
|
||||
|
||||
> *provider 点菜,core 后厨,结果回桌。*
|
||||
> **Pi 边界**:工具执行边界 —— provider 只发请求,执行永远在 core;结果回到消息流,成为历史。
|
||||
|
||||
[上一节:s03](../s03_provider_event_stream/) → `s04` → [下一节:s05](../s05_tool_hook_boundary/)
|
||||
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
s02 给了 provider 工具说明,s03 给了事件流。但到现在为止,provider 还不能**真的用上**这些工具——它只会说文本,没法告诉 core"去把那个工具跑一下"。
|
||||
|
||||
而且就算能请求,往往也不是一趟就够:provider 用完一个工具,看了结果,可能还要再用一个。这需要一个来回多次的循环。
|
||||
|
||||
s04 要补上这一环:让 provider 能**请求**执行工具,core 执行后把结果**送回去**,循环到 provider 不再请求为止。
|
||||
|
||||
---
|
||||
|
||||
## 解决方案
|
||||
|
||||
事件里多出一种 `tool_call`:provider 用它说"我要调用某个工具"。core 收到后做三步:
|
||||
|
||||
```text
|
||||
tool_call → registry.run() → ToolResultMessage → 进 messages
|
||||
```
|
||||
|
||||
工具结果和 user / assistant 消息**平级**,也存进 messages。这样 provider 下一轮就能看到"刚才那个工具返回了什么",决定是接着用工具,还是收尾。
|
||||
|
||||
> **[U1 升级]** 一轮推进的函数从 `runOneTurn` 变成 `runEventedToolLoop`:原来跑一趟就结束,现在套了一个循环。这是受控升级——单轮没法表达"来回多趟",所以是替换。
|
||||
|
||||
这一节还顺手立两条保护:循环有**轮次上限**,provider 一直请求也不会死循环(R5);工具执行**抛错也不崩**,错误会被包成一条结果消息送回去(R4)。
|
||||
|
||||
---
|
||||
|
||||
## 工作原理
|
||||
|
||||
**先定义请求和结果。** provider 的请求叫 ToolCall,结果叫 ToolResultMessage。
|
||||
|
||||
```ts
|
||||
export type ToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ToolResultMessage = {
|
||||
role: "toolResult";
|
||||
toolCallId: string;
|
||||
content: string;
|
||||
};
|
||||
```
|
||||
|
||||
工具结果也是一种消息,并入 AgentMessage;停止原因也多一个 `toolUse`,表示"provider 还想用工具,先别停"。
|
||||
|
||||
```ts
|
||||
export type StopReason = "stop" | "toolUse" | "error";
|
||||
export type AgentMessage = UserMessage | AssistantMessage | ToolResultMessage;
|
||||
```
|
||||
|
||||
**事件里加 tool_call。** s03 的三种事件都在,多出来的是 `tool_call`。
|
||||
|
||||
```ts
|
||||
export type ProviderEvent =
|
||||
| { type: "message_start" }
|
||||
| { type: "text_delta"; text: string }
|
||||
| { type: "tool_call"; call: ToolCall }
|
||||
| { type: "message_end"; stopReason: StopReason };
|
||||
```
|
||||
|
||||
**registry 学会执行。** `run(call)` 拿到工具就跑 handler;碰到没注册过的名字,不抛错,返回一句说明。
|
||||
|
||||
```ts
|
||||
run(call: ToolCall): string {
|
||||
const tool = this.tools.get(call.name);
|
||||
if (!tool) {
|
||||
return `unknown tool: ${call.name}`;
|
||||
}
|
||||
return tool.handler(call.input);
|
||||
}
|
||||
```
|
||||
|
||||
**核心是循环。** `runEventedToolLoop` 每一轮:构造输入 → 收事件 → 遇到 tool_call 就执行、把结果存成消息 → 一轮事件收完后,看还有没有 tool_call。有就再来一轮,没有就收尾。轮次超过上限会主动停下。
|
||||
|
||||
```ts
|
||||
while (true) {
|
||||
turns += 1;
|
||||
if (turns > MAX_TURNS) { /* 主动停止 */ }
|
||||
|
||||
// ... 收事件,遇 tool_call 就 registry.run + 存 ToolResultMessage ...
|
||||
|
||||
if (!sawToolCall || stopReason !== "toolUse") {
|
||||
// 存下 assistant 消息,结束
|
||||
return assistant;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
工具执行包在 try/catch 里:handler 抛错,错误变成 `error: ...` 这条结果消息,循环继续——provider 会看到这个错误,自己决定怎么办。
|
||||
|
||||
> 这一节真正建立的是**工具执行边界**:provider 只发请求,执行永远发生在 core 这边;而且工具结果不丢,它回到消息流里,成为对话历史的一部分。后面 s05 会在"执行"这个动作的前后加插口,但"请求在 provider、执行在 core"这条分隔,从这里立起来。
|
||||
|
||||
---
|
||||
|
||||
## 试一下
|
||||
|
||||
运行:
|
||||
|
||||
```sh
|
||||
npm run s04
|
||||
```
|
||||
|
||||
输出类似:
|
||||
|
||||
```text
|
||||
s04: Evented Tool Loop
|
||||
|
||||
[user]
|
||||
现在几点?
|
||||
|
||||
message_start
|
||||
tool_call: current_time
|
||||
tool_result: 2026-01-01T00:00:00Z
|
||||
message_end: toolUse
|
||||
message_start
|
||||
text_delta: 工具结果是:2026-01-01T00:00:00Z
|
||||
message_end: stop
|
||||
|
||||
[assistant]
|
||||
content: 工具结果是:2026-01-01T00:00:00Z
|
||||
stopReason: stop
|
||||
|
||||
[state]
|
||||
user -> toolResult -> assistant
|
||||
```
|
||||
|
||||
观察重点:第一轮 provider 请求 `current_time`,core 执行后结果进了 messages(`tool_result`);第二轮 provider 看到结果,输出文本并结束;`[state]` 里能看到 `user -> toolResult -> assistant` 这条链。
|
||||
|
||||
---
|
||||
|
||||
## 接入主线
|
||||
|
||||
s04 在 s03 上累积。相对 s03 的变更:
|
||||
|
||||
| 组件 | s03 | s04 |
|
||||
| --- | --- | --- |
|
||||
| `StopReason` | `stop \| error` | `stop \| toolUse \| error`(R1 加 toolUse) |
|
||||
| `AgentMessage` | `User \| Assistant` | `User \| Assistant \| ToolResultMessage`(R1) |
|
||||
| 新增类型 | — | `ToolCall` / `ToolResultMessage` |
|
||||
| `ProviderEvent` | 三种 | 加 `tool_call`(R1:message_start 保留) |
|
||||
| `ToolRegistry` | `register / getSpecs` | 加 `run(call)`(R2) |
|
||||
| 一轮推进 | `runOneTurn`(单趟) | **`runEventedToolLoop`**(U1 升级,带循环) |
|
||||
| 事件收集 | `collectAssistantMessage`(单函数) | 被循环内联收集取代(要处理 tool_call) |
|
||||
| 保护 | — | `MAX_TURNS` 终止(R5)、工具错误捕获(R4) |
|
||||
|
||||
**焊接点**:循环内 `tool_call` → `registry.run(call)` → 结果存成 ToolResultMessage 进 messages → 下一轮 provider 输入;tools 始终取 `registry.getSpecs()`,和能执行的工具是同一份来源。
|
||||
|
||||
---
|
||||
|
||||
## 接下来
|
||||
|
||||
现在 provider 一请求,工具就直接执行了——没有给 core 留任何介入的余地。
|
||||
|
||||
下一节会在执行这个动作的前后留两个插口:执行前可以拦下来,执行后可以改写结果。
|
||||
|
||||
进入下一节:[s05](../s05_tool_hook_boundary/)。
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>Pi 源码溯源:工具循环的并发与终止</summary>
|
||||
|
||||
教学版的 `runEventedToolLoop` 是串行循环、`MAX_TURNS` 兜底。Pi 的工具循环(`packages/agent`)要复杂得多——最关键的是**并发执行**。
|
||||
|
||||
### 源码在哪
|
||||
|
||||
- `packages/agent/src/agent-loop.ts:373` — `executeToolCalls`(工具执行总入口)
|
||||
- `packages/agent/src/agent-loop.ts:451` — `executeToolCallsParallel`(并发执行)
|
||||
- `packages/agent/src/types.ts:29` — `ToolExecutionMode`
|
||||
|
||||
### 一个 stop 可以带多个工具调用
|
||||
|
||||
教学版一轮只处理一个 `tool_call`。Pi 里模型一次回复可以带**多个** tool call(`agent-loop.ts:207`):
|
||||
|
||||
```ts
|
||||
if (toolCalls.length > 0) {
|
||||
const batch = await executeToolCalls(currentContext, message, config, signal, emit);
|
||||
toolResults.push(...batch.messages);
|
||||
hasMoreToolCalls = !batch.terminate;
|
||||
}
|
||||
```
|
||||
|
||||
一次 batch 里所有工具一起处理,结果一起回写。
|
||||
|
||||
### sequential vs parallel
|
||||
|
||||
教学版串行。Pi 有两种执行模式(`types.ts:29`):
|
||||
|
||||
```ts
|
||||
type ToolExecutionMode = "sequential" | "parallel";
|
||||
```
|
||||
|
||||
并发版 `executeToolCallsParallel`(`agent-loop.ts:451`)用 `Promise.all` 同时跑多个工具,但**事件顺序保持**——prepare 阶段(参数校验 + beforeHook,s05)是顺序的,execute 阶段才并发,结果按完成时间发事件。哪个工具该并发、哪个该独占,由工具自己的 `executionMode` 决定(s02 提过 AgentTool 有这个字段)。
|
||||
|
||||
### 工具能主动终止整个循环
|
||||
|
||||
教学版的终止条件是"provider 不再发 tool_call"或"撞 MAX_TURNS"。Pi 多一个:工具结果能带 `terminate`(`types.ts`):
|
||||
|
||||
```ts
|
||||
interface AgentToolResult<T> {
|
||||
content: (TextContent | ImageContent)[];
|
||||
details: T;
|
||||
terminate?: boolean; // 这个工具要求停止整个 agent
|
||||
}
|
||||
```
|
||||
|
||||
只有 batch 里**所有**工具都 `terminate` 才真停——防止单个工具误杀整个会话。教学版的 MAX_TURNS 是被动兜底,Pi 的 terminate 是工具主动喊停。
|
||||
|
||||
### 执行前后有插口(s05 的预告)
|
||||
|
||||
教学版直接 `registry.run`。Pi 的每个工具执行经过 `prepareToolCall`(参数校验 + beforeToolCall hook)→ `execute` → `finalizeExecutedToolCall`(afterToolCall hook)。这就是 s05 的 hook 真实位置——它长在并发执行的 prepare/finalize 两侧。
|
||||
|
||||
### 一句话
|
||||
|
||||
教学版的循环立的是"请求 → 执行 → 回写 → 再来"。Pi 把它扩成 batch 并发 + 工具主动 terminate + 执行前后插口,但循环主干(收 tool_call、执行、结果回 messages、看是否继续)和教学版一致。
|
||||
|
||||
</details>
|
||||
306
learn-pi-agent/s04_evented_tool_loop/code.ts
Normal file
306
learn-pi-agent/s04_evented_tool_loop/code.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
// s04: Evented Tool Loop — mini Pi 的第 4 版
|
||||
//
|
||||
// [U1 升级] runOneTurn → runEventedToolLoop:provider 请求工具,core 执行后把结果送回,循环到 provider 不再请求为止。
|
||||
// 词汇边界:本章新增 ToolCall / ToolResultMessage / tool_call / toolUse / runEventedToolLoop / run。
|
||||
// 关键:tools 取 registry.getSpecs()(单一数据源,不硬编码);循环有上限(R5);工具出错不崩(R4)。
|
||||
|
||||
declare const process: {
|
||||
exitCode?: number;
|
||||
};
|
||||
|
||||
// —— 停止原因(R1:s04 加 toolUse)——
|
||||
export type StopReason = "stop" | "toolUse" | "error";
|
||||
|
||||
// —— s01 起:消息 ——
|
||||
export type UserMessage = {
|
||||
role: "user";
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type AssistantMessage = {
|
||||
role: "assistant";
|
||||
content: string;
|
||||
stopReason: StopReason;
|
||||
};
|
||||
|
||||
// s04 新增:工具执行结果也是一种消息,和 user / assistant 平级。
|
||||
export type ToolResultMessage = {
|
||||
role: "toolResult";
|
||||
toolCallId: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
// s04 起:AgentMessage 并入 ToolResultMessage(R1 只增)
|
||||
export type AgentMessage = UserMessage | AssistantMessage | ToolResultMessage;
|
||||
|
||||
// —— s01 起:core 内部状态 ——
|
||||
export type AgentState = {
|
||||
messages: AgentMessage[];
|
||||
};
|
||||
|
||||
// —— s02 起:工具契约 ——
|
||||
export type ToolSpec = {
|
||||
name: string;
|
||||
description: string;
|
||||
input: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ToolHandler = (input: Record<string, string>) => string;
|
||||
|
||||
// s04 新增:provider 对一个工具的调用请求。
|
||||
export type ToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, string>;
|
||||
};
|
||||
|
||||
export type Tool = {
|
||||
spec: ToolSpec;
|
||||
handler: ToolHandler;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// s04 新增:执行工具。未知工具不崩,返回一句说明。
|
||||
run(call: ToolCall): string {
|
||||
const tool = this.tools.get(call.name);
|
||||
if (!tool) {
|
||||
return `unknown tool: ${call.name}`;
|
||||
}
|
||||
return tool.handler(call.input);
|
||||
}
|
||||
}
|
||||
|
||||
// —— provider 对外消息(s04:加 toolResult 形态)——
|
||||
export type ProviderMessage =
|
||||
| { role: "user" | "assistant"; content: string }
|
||||
| { role: "toolResult"; toolCallId: string; content: string };
|
||||
|
||||
// provider 输入(R1 只增)
|
||||
export type ProviderInput = {
|
||||
messages: ProviderMessage[];
|
||||
tools: ToolSpec[];
|
||||
};
|
||||
|
||||
// —— s03 起:事件流(s04 加 tool_call,保留 message_start,R1)——
|
||||
export type ProviderEvent =
|
||||
| { type: "message_start" }
|
||||
| { type: "text_delta"; text: string }
|
||||
| { type: "tool_call"; call: ToolCall }
|
||||
| { type: "message_end"; stopReason: StopReason };
|
||||
|
||||
export interface Provider {
|
||||
stream(input: ProviderInput): AsyncGenerator<ProviderEvent>;
|
||||
}
|
||||
|
||||
// —— s01 起:输出抽象(R7)——
|
||||
export type Output = {
|
||||
log(line: string): void;
|
||||
};
|
||||
|
||||
export function createConsoleOutput(): Output {
|
||||
return { log: (line) => console.log(line) };
|
||||
}
|
||||
|
||||
// ============ 构造函数 ============
|
||||
|
||||
export function createInitialState(): AgentState {
|
||||
return { messages: [] };
|
||||
}
|
||||
|
||||
export function createUserMessage(content: string): UserMessage {
|
||||
return { role: "user", content };
|
||||
}
|
||||
|
||||
// s04:buildProviderInput 要把 toolResult 消息也正确转给 provider。
|
||||
export function buildProviderInput(
|
||||
state: AgentState,
|
||||
registry: ToolRegistry,
|
||||
): ProviderInput {
|
||||
return {
|
||||
messages: state.messages.map((message) => {
|
||||
if (message.role === "toolResult") {
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId: message.toolCallId,
|
||||
content: message.content,
|
||||
};
|
||||
}
|
||||
return { role: message.role, content: message.content };
|
||||
}),
|
||||
tools: registry.getSpecs(),
|
||||
};
|
||||
}
|
||||
|
||||
// ============ s04 [U1]:工具循环(取代 runOneTurn)============
|
||||
// s03 的 collectAssistantMessage(只攒文本事件)被这里的循环内联收集取代——
|
||||
// 循环要处理 tool_call,所以收集逻辑直接长在循环里。
|
||||
|
||||
// R5:循环必须有上限。否则一个一直请求工具的 provider 会让 core 死循环。
|
||||
const MAX_TURNS = 8;
|
||||
|
||||
export async function runEventedToolLoop(
|
||||
state: AgentState,
|
||||
provider: Provider,
|
||||
registry: ToolRegistry,
|
||||
userInput: string,
|
||||
output: Output,
|
||||
): Promise<AssistantMessage> {
|
||||
state.messages.push(createUserMessage(userInput));
|
||||
|
||||
let turns = 0;
|
||||
|
||||
while (true) {
|
||||
turns += 1;
|
||||
if (turns > MAX_TURNS) {
|
||||
output.log(`(达到最大轮次 ${MAX_TURNS},停止)`);
|
||||
const stopped: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: "(达到最大轮次,停止)",
|
||||
stopReason: "stop",
|
||||
};
|
||||
state.messages.push(stopped);
|
||||
return stopped;
|
||||
}
|
||||
|
||||
const providerInput = buildProviderInput(state, registry);
|
||||
let content = "";
|
||||
let stopReason: StopReason = "stop";
|
||||
let sawToolCall = false;
|
||||
|
||||
for await (const event of provider.stream(providerInput)) {
|
||||
if (event.type === "message_start") {
|
||||
output.log("message_start");
|
||||
} else if (event.type === "text_delta") {
|
||||
output.log(`text_delta: ${event.text}`);
|
||||
content += event.text;
|
||||
} else if (event.type === "tool_call") {
|
||||
sawToolCall = true;
|
||||
output.log(`tool_call: ${event.call.name}`);
|
||||
|
||||
// R4:工具执行抛错也不崩,错误变成一条结果消息送回去。
|
||||
let result: string;
|
||||
try {
|
||||
result = registry.run(event.call);
|
||||
} catch (error) {
|
||||
result = `error: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
|
||||
const resultMessage: ToolResultMessage = {
|
||||
role: "toolResult",
|
||||
toolCallId: event.call.id,
|
||||
content: result,
|
||||
};
|
||||
state.messages.push(resultMessage);
|
||||
output.log(`tool_result: ${result}`);
|
||||
} else if (event.type === "message_end") {
|
||||
stopReason = event.stopReason;
|
||||
output.log(`message_end: ${stopReason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 没有 tool_call,或 provider 明确不再用工具(stopReason 不是 toolUse),就结束。
|
||||
if (!sawToolCall || stopReason !== "toolUse") {
|
||||
const assistant: AssistantMessage = { role: "assistant", content, stopReason };
|
||||
state.messages.push(assistant);
|
||||
return assistant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Demo Provider(fake)============
|
||||
// 演示一个完整的工具循环:第一轮请求工具,第二轮收到结果后输出文本并结束。
|
||||
export class DemoProvider implements Provider {
|
||||
public lastInput: ProviderInput | undefined;
|
||||
|
||||
async *stream(input: ProviderInput): AsyncGenerator<ProviderEvent> {
|
||||
this.lastInput = input;
|
||||
const last = input.messages[input.messages.length - 1];
|
||||
|
||||
yield { type: "message_start" };
|
||||
|
||||
if (last?.role === "toolResult") {
|
||||
// 工具结果回来了:输出文本,正常结束。
|
||||
yield { type: "text_delta", text: `工具结果是:${last.content}` };
|
||||
yield { type: "message_end", stopReason: "stop" };
|
||||
return;
|
||||
}
|
||||
|
||||
// 否则请求调用一个工具。
|
||||
yield {
|
||||
type: "tool_call",
|
||||
call: { id: "call_1", name: "current_time", input: {} },
|
||||
};
|
||||
yield { type: "message_end", stopReason: "toolUse" };
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 演示脚手架 ============
|
||||
|
||||
function createRegistry(): ToolRegistry {
|
||||
const registry = new ToolRegistry();
|
||||
|
||||
registry.register({
|
||||
spec: {
|
||||
name: "current_time",
|
||||
description: "返回一个固定的演示时间",
|
||||
input: {},
|
||||
},
|
||||
handler: () => "2026-01-01T00:00:00Z",
|
||||
});
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
function printAssistantMessage(output: Output, message: AssistantMessage): void {
|
||||
output.log("[assistant]");
|
||||
output.log(`content: ${message.content}`);
|
||||
output.log(`stopReason: ${message.stopReason}`);
|
||||
output.log("");
|
||||
}
|
||||
|
||||
function printState(output: Output, state: AgentState): void {
|
||||
output.log("[state]");
|
||||
output.log(state.messages.map((message) => message.role).join(" -> "));
|
||||
output.log("");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const output = createConsoleOutput();
|
||||
const state = createInitialState();
|
||||
const registry = createRegistry();
|
||||
const provider = new DemoProvider();
|
||||
|
||||
output.log("s04: Evented Tool Loop");
|
||||
output.log("");
|
||||
|
||||
output.log("[user]");
|
||||
output.log("现在几点?");
|
||||
output.log("");
|
||||
|
||||
const assistant = await runEventedToolLoop(
|
||||
state,
|
||||
provider,
|
||||
registry,
|
||||
"现在几点?",
|
||||
output,
|
||||
);
|
||||
output.log("");
|
||||
|
||||
printAssistantMessage(output, assistant);
|
||||
printState(output, state);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user