mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-06-22 05:04:30 +08:00
refactor: organize agent harness courses
This commit is contained in:
190
learn-pi-agent/s06_turn_snapshot/README.md
Normal file
190
learn-pi-agent/s06_turn_snapshot/README.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# s06: Turn Snapshot — 一轮开始,先拍一张
|
||||
|
||||
> *开始即冻结:本轮用的东西,后面改了也不算。*
|
||||
> **Pi 边界**:一轮状态边界 —— 一轮一旦开始,它依赖的配置就定死了,外部怎么变都不影响这一轮。
|
||||
|
||||
[上一节:s05](../s05_tool_hook_boundary/) → `s06` → [下一节:s07](../s07_session_tree/)
|
||||
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
前面几节里,每一轮的信息都是**现场读**的:provider 输入每次都从当前的 state 和 registry 临时拼。
|
||||
|
||||
问题在于:如果一轮进行到一半,外部又改了 registry(加了个工具、删了个工具),这一轮就前后对不上了——provider 这一轮第一次看到的工具列表,和后来看到的不一样。一轮执行到一半被外部改动干扰,结果就说不清。
|
||||
|
||||
可以先看一个小事故:provider 第一轮看到 1 个工具,工具执行过程中外部又注册了第 2 个工具。下一轮如果重新读 registry,同一个 turn 里的工具集合就变了。模型看到的世界前后不一致,调试时很难判断到底是哪一轮出了问题。
|
||||
|
||||
s06 要在一轮开始时,把本轮依赖的东西**先固定下来**。
|
||||
|
||||
---
|
||||
|
||||
## 解决方案
|
||||
|
||||
一轮开始先拍一份快照 `TurnSnapshot`,固定 **messages** 和 **tools**。之后本轮的 tools 都用快照里的,不管外部怎么改 registry。
|
||||
|
||||
```text
|
||||
AgentState + tools → TurnSnapshot → 本轮 ProviderInput
|
||||
```
|
||||
|
||||
先记住这条:snapshot 不是把整个世界冻住,只是把本轮需要稳定的输入固定下来。
|
||||
|
||||
这里有个关键区分(也是和 Pi 对齐的地方):**快照固定的是"外部可变的配置"(tools),不是所有东西。**
|
||||
|
||||
- **tools**:外部能改(registry 随时变),所以要固定。
|
||||
- **messages**:core 内部的,循环里 toolResult 会不断追加,取实时值。
|
||||
- **model**:是 **agent 级的跨轮配置**,放 `AgentState`,**不进单轮快照**——对齐 Pi 的 `AgentContext`(它也不含 model)。
|
||||
|
||||
---
|
||||
|
||||
## 工作原理
|
||||
|
||||
**先定义快照。** 两个字段:本轮的消息、本轮的工具说明。
|
||||
|
||||
```ts
|
||||
export type TurnSnapshot = {
|
||||
messages: ProviderMessage[];
|
||||
tools: ToolSpec[];
|
||||
};
|
||||
```
|
||||
|
||||
**model 不在快照里,在 AgentState。** 这是和"把 model 当输入参数"的区别——model 是 agent 的跨轮配置,一轮内不变、跨轮可换,所以它属于状态,不属于单轮快照。
|
||||
|
||||
```ts
|
||||
export type AgentState = {
|
||||
messages: AgentMessage[]; // s07 会升级为 SessionTree
|
||||
model: string; // s06 起加:跨轮配置
|
||||
};
|
||||
```
|
||||
|
||||
**在循环开始前拍。** `runEventedToolLoop` 进循环前,由调用方先 `createTurnSnapshot`。之后整个循环都用这份快照的 tools。
|
||||
|
||||
```ts
|
||||
const snapshot = createTurnSnapshot(state, registry);
|
||||
```
|
||||
|
||||
**本轮输入从快照取。** `buildProviderInputFromSnapshot`:messages 用实时的(循环内会增长),tools 用快照的(固定)。
|
||||
|
||||
```ts
|
||||
export function buildProviderInputFromSnapshot(
|
||||
snapshot: TurnSnapshot,
|
||||
state: AgentState,
|
||||
): ProviderInput {
|
||||
return {
|
||||
messages: toProviderMessages(state.messages), // 实时
|
||||
tools: snapshot.tools, // 固定
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
> 这一节真正建立的是**一轮状态边界**:一轮一旦开始,它依赖的配置(tools)就冻结了。messages 该增长还增长,model 在 state 里跨轮——这正好对齐 Pi 的 `AgentContext`(固定 systemPrompt/messages/tools,model 在 `AgentState`)。
|
||||
|
||||
---
|
||||
|
||||
## 试一下
|
||||
|
||||
运行:
|
||||
|
||||
```sh
|
||||
npm run s06
|
||||
```
|
||||
|
||||
输出类似:
|
||||
|
||||
```text
|
||||
s06: Turn Snapshot
|
||||
|
||||
[snapshot 固定性]
|
||||
snapshot.tools: 1
|
||||
registry 现在: 2
|
||||
state.model: demo-small(跨轮配置,不在 snapshot)
|
||||
|
||||
[user]
|
||||
现在几点?
|
||||
|
||||
message_start
|
||||
tool_call: current_time
|
||||
...
|
||||
message_end: stop
|
||||
|
||||
[provider 看到的 tools]
|
||||
tools: 1
|
||||
```
|
||||
|
||||
观察重点:`[snapshot 固定性]` 里 snapshot 拍下时只有 1 个工具,之后 registry 加到 2 个,但快照没变;`[provider 看到的 tools]` 也是 1——本轮 provider 自始至终只看到快照里的那一个。model 在 `state.model`,不进快照。
|
||||
|
||||
---
|
||||
|
||||
## 接入主线
|
||||
|
||||
s06 在 s05 上累积。相对 s05 的变更:
|
||||
|
||||
| 组件 | s05 | s06 |
|
||||
| --- | --- | --- |
|
||||
| `AgentState` | `{ messages }` | `{ messages, model }`(加 model 跨轮配置,对齐 Pi) |
|
||||
| 新增类型 | — | `TurnSnapshot { messages, tools }` |
|
||||
| 新增函数 | — | `createTurnSnapshot` / `buildProviderInputFromSnapshot` |
|
||||
| `runEventedToolLoop` | `(..., userInput, output)` | 接收外部拍好的 `snapshot`(替换 userInput) |
|
||||
|
||||
**焊接点**:调用方先 `createTurnSnapshot(state, registry)`;循环内 `buildProviderInputFromSnapshot(snapshot, state)`——messages 实时、tools 固定。model 在 AgentState,不进 ProviderInput/snapshot(对齐 Pi 的 `Context` 不含 model)。
|
||||
|
||||
---
|
||||
|
||||
## 接下来
|
||||
|
||||
到现在为止,历史还只是一根直线——messages 是个数组,只能一条接一条往后排。
|
||||
|
||||
下一节会让历史能分叉:从中间某条岔出去,再走一条不同的路。
|
||||
|
||||
进入下一节:[s07](../s07_session_tree/)。
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>Pi 源码溯源:AgentContext —— 每轮一份不可变快照</summary>
|
||||
|
||||
教学版的 `TurnSnapshot` 固定 messages/tools。Pi 的等价物叫 `AgentContext`,每轮新建、不可变。
|
||||
|
||||
### 源码在哪
|
||||
|
||||
- `packages/agent/src/types.ts:387` — `AgentContext`
|
||||
- `packages/agent/src/agent-loop.ts:103` — 每轮拷贝构造
|
||||
|
||||
### AgentContext 的真实形状
|
||||
|
||||
```ts
|
||||
interface AgentContext {
|
||||
systemPrompt: string; // 本轮系统提示(固定)
|
||||
messages: AgentMessage[]; // 本轮对话历史(固定)
|
||||
tools?: AgentTool[]; // 本轮工具(固定)
|
||||
}
|
||||
```
|
||||
|
||||
教学版的 TurnSnapshot 字段(messages/tools)是 AgentContext 的子集——Pi 还固定了 systemPrompt(s08 会引入)。**注意 Pi 把 model 放在 `AgentState`(不在 AgentContext)**,因为 model 是跨轮的配置,不是单轮快照内容。教学版 s06 正是对齐了这点:model 在 AgentState,TurnSnapshot 不含 model。
|
||||
|
||||
### 每轮新建,浅拷贝
|
||||
|
||||
`agent-loop.ts:103`:
|
||||
|
||||
```ts
|
||||
const currentContext: AgentContext = {
|
||||
...context,
|
||||
messages: [...context.messages, ...prompts], // 浅拷贝新数组
|
||||
};
|
||||
```
|
||||
|
||||
每轮创建新的 context 对象,messages 用新数组——本轮往里 push toolResult 不会污染原始 context。这正是教学版 snapshot 的"固定"语义。
|
||||
|
||||
### turn_start / turn_end 事件
|
||||
|
||||
Pi 在每轮边界发事件(`types.ts:408`):`turn_start` 和 `turn_end`(带 message 和 toolResults)。UI 和 extension(s09)靠它们观察一轮起止——教学版没有"轮"事件。
|
||||
|
||||
### convertToLlm:发之前再过滤
|
||||
|
||||
`AgentContext.messages` 是 core 内部的完整历史。真正发给 provider 前,Pi 还有一道 `convertToLlm` 过滤——把不该发给 LLM 的消息剔掉。snapshot 固定 core 侧,convertToLlm 管 provider 侧,两道关一起保证一轮输入既稳定又干净。
|
||||
|
||||
### 一句话
|
||||
|
||||
教学版的 TurnSnapshot 立的是"一轮开始把输入固定下来"。Pi 用 `AgentContext` 坐实它:每轮新建不可变副本 + turn 事件 + 发送前的 convertToLlm 过滤。关键对齐点:**model 在 AgentState 不进快照**,两边一致。
|
||||
|
||||
</details>
|
||||
282
learn-pi-agent/s06_turn_snapshot/code.ts
Normal file
282
learn-pi-agent/s06_turn_snapshot/code.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
// s06: Turn Snapshot — mini Pi 的第 6 版
|
||||
//
|
||||
// 一轮开始时先拍一份快照:messages/tools 固定下来,本轮不再受外部改动影响。
|
||||
// 词汇边界:本章新增 TurnSnapshot / createTurnSnapshot / buildProviderInputFromSnapshot。
|
||||
// 关键(对齐 Pi):model 是跨轮配置,放 AgentState,不进单轮快照(Pi 的 AgentContext 也不含 model)。
|
||||
|
||||
declare const process: {
|
||||
exitCode?: number;
|
||||
};
|
||||
|
||||
// —— 停止原因(s04 起)——
|
||||
export type StopReason = "stop" | "toolUse" | "error";
|
||||
|
||||
// —— 消息 ——
|
||||
export type UserMessage = { role: "user"; content: string };
|
||||
export type AssistantMessage = { role: "assistant"; content: string; stopReason: StopReason };
|
||||
export type ToolResultMessage = { role: "toolResult"; toolCallId: string; content: string };
|
||||
export type AgentMessage = UserMessage | AssistantMessage | ToolResultMessage;
|
||||
|
||||
// —— core 内部状态(s06:加 model 跨轮配置,对齐 Pi AgentState)——
|
||||
export type AgentState = {
|
||||
messages: AgentMessage[]; // s07 会升级为 SessionTree(U1)
|
||||
model: string; // s06 起加:跨轮配置,不在单轮快照里
|
||||
};
|
||||
|
||||
// —— 工具契约 ——
|
||||
export type ToolSpec = { name: string; description: string; input: Record<string, string> };
|
||||
export type ToolHandler = (input: Record<string, string>) => string;
|
||||
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); }
|
||||
count(): number { return this.tools.size; }
|
||||
run(call: ToolCall): string {
|
||||
const tool = this.tools.get(call.name);
|
||||
if (!tool) return `unknown tool: ${call.name}`;
|
||||
return tool.handler(call.input);
|
||||
}
|
||||
}
|
||||
|
||||
// —— provider 对外(对齐 Pi Context:messages + tools;model 在 state)——
|
||||
export type ProviderMessage =
|
||||
| { role: "user" | "assistant"; content: string }
|
||||
| { role: "toolResult"; toolCallId: string; content: string };
|
||||
export type ProviderInput = { messages: ProviderMessage[]; tools: ToolSpec[] };
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
export type Output = { log(line: string): void };
|
||||
export function createConsoleOutput(): Output { return { log: (line) => console.log(line) }; }
|
||||
|
||||
// —— s05 起:执行插口 ——
|
||||
export type BeforeToolCallResult = { type: "allow" } | { type: "block"; reason: string };
|
||||
export type ToolHooks = {
|
||||
beforeToolCall?: (call: ToolCall) => BeforeToolCallResult;
|
||||
afterToolCall?: (call: ToolCall, result: string) => string;
|
||||
};
|
||||
|
||||
export function executeToolCall(registry: ToolRegistry, hooks: ToolHooks, call: ToolCall): ToolResultMessage {
|
||||
const before = hooks.beforeToolCall?.(call) ?? { type: "allow" };
|
||||
if (before.type === "block") {
|
||||
return { role: "toolResult", toolCallId: call.id, content: `blocked: ${before.reason}` };
|
||||
}
|
||||
let result: string;
|
||||
try {
|
||||
result = registry.run(call);
|
||||
} catch (error) {
|
||||
result = `error: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
const finalResult = hooks.afterToolCall?.(call, result) ?? result;
|
||||
return { role: "toolResult", toolCallId: call.id, content: finalResult };
|
||||
}
|
||||
|
||||
// ============ s06 新增:一轮快照 ============
|
||||
|
||||
// 对齐 Pi AgentContext:固定 messages/tools。model 在 AgentState,不进快照。
|
||||
export type TurnSnapshot = {
|
||||
messages: ProviderMessage[];
|
||||
tools: ToolSpec[];
|
||||
};
|
||||
|
||||
function toProviderMessages(messages: AgentMessage[]): ProviderMessage[] {
|
||||
return messages.map((message) => {
|
||||
if (message.role === "toolResult") {
|
||||
return { role: "toolResult", toolCallId: message.toolCallId, content: message.content };
|
||||
}
|
||||
return { role: message.role, content: message.content };
|
||||
});
|
||||
}
|
||||
|
||||
export function createTurnSnapshot(state: AgentState, registry: ToolRegistry): TurnSnapshot {
|
||||
return {
|
||||
messages: toProviderMessages(state.messages),
|
||||
tools: registry.getSpecs(),
|
||||
};
|
||||
}
|
||||
|
||||
// 本轮 provider 输入:messages 取实时(循环内会增长),tools 取快照(固定)。
|
||||
// model 在 state,不进 ProviderInput(对齐 Pi:调用 provider 时单独传 model)。
|
||||
export function buildProviderInputFromSnapshot(
|
||||
snapshot: TurnSnapshot,
|
||||
state: AgentState,
|
||||
): ProviderInput {
|
||||
return {
|
||||
messages: toProviderMessages(state.messages),
|
||||
tools: snapshot.tools,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 构造函数 ============
|
||||
export function createInitialState(model = "demo-small"): AgentState {
|
||||
return { messages: [], model };
|
||||
}
|
||||
export function createUserMessage(content: string): UserMessage { return { role: "user", content }; }
|
||||
|
||||
export function snapshotToolsCount(snapshot: TurnSnapshot): number {
|
||||
return snapshot.tools.length;
|
||||
}
|
||||
|
||||
// ============ 工具循环(s06:接收外部拍好的 snapshot)============
|
||||
const MAX_TURNS = 8;
|
||||
|
||||
export async function runEventedToolLoop(
|
||||
state: AgentState,
|
||||
provider: Provider,
|
||||
registry: ToolRegistry,
|
||||
hooks: ToolHooks,
|
||||
snapshot: TurnSnapshot,
|
||||
output: Output,
|
||||
): Promise<AssistantMessage> {
|
||||
let turns = 0;
|
||||
while (true) {
|
||||
turns += 1;
|
||||
if (turns > MAX_TURNS) {
|
||||
const stopped: AssistantMessage = {
|
||||
role: "assistant", content: "(达到最大轮次,停止)", stopReason: "stop",
|
||||
};
|
||||
state.messages.push(stopped);
|
||||
return stopped;
|
||||
}
|
||||
const providerInput = buildProviderInputFromSnapshot(snapshot, state);
|
||||
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}`);
|
||||
const resultMessage = executeToolCall(registry, hooks, event.call);
|
||||
state.messages.push(resultMessage);
|
||||
output.log(`tool_result: ${resultMessage.content}`);
|
||||
} else if (event.type === "message_end") { stopReason = event.stopReason; output.log(`message_end: ${stopReason}`); }
|
||||
}
|
||||
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 createHooks(output: Output): ToolHooks {
|
||||
return {
|
||||
beforeToolCall(call) {
|
||||
output.log(`[beforeToolCall] allow: ${call.name}`);
|
||||
return { type: "allow" };
|
||||
},
|
||||
afterToolCall(call, result) {
|
||||
output.log(`[afterToolCall] ${call.name} -> ${result}`);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function printAssistantMessage(output: Output, message: AssistantMessage): void {
|
||||
output.log("[assistant]");
|
||||
output.log(`content: ${message.content}`);
|
||||
output.log(`stopReason: ${message.stopReason}`);
|
||||
output.log("");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const output = createConsoleOutput();
|
||||
const state = createInitialState("demo-small");
|
||||
const registry = createRegistry();
|
||||
const hooks = createHooks(output);
|
||||
const provider = new DemoProvider();
|
||||
|
||||
output.log("s06: Turn Snapshot");
|
||||
output.log("");
|
||||
|
||||
// 1) 一轮开始:push 用户消息,拍快照(此刻 registry 只有 current_time)。
|
||||
state.messages.push(createUserMessage("现在几点?"));
|
||||
const snapshot = createTurnSnapshot(state, registry);
|
||||
|
||||
// 2) 拍完之后,外部又往 registry 加了一个工具。
|
||||
registry.register({
|
||||
spec: { name: "echo", description: "原样返回输入", input: { text: "文本" } },
|
||||
handler: (input) => input.text ?? "(空)",
|
||||
});
|
||||
|
||||
// 3) 验证固定性:快照没变,但 registry 已经多了工具;model 在 state(跨轮),不进快照。
|
||||
output.log("[snapshot 固定性]");
|
||||
output.log(`snapshot.tools: ${snapshotToolsCount(snapshot)}`);
|
||||
output.log(`registry 现在: ${registry.count()}`);
|
||||
output.log(`state.model: ${state.model}(跨轮配置,不在 snapshot)`);
|
||||
output.log("");
|
||||
|
||||
output.log("[user]");
|
||||
output.log("现在几点?");
|
||||
output.log("");
|
||||
|
||||
// 4) 跑循环:本轮 tools 用 snapshot 的(仍只有 current_time),不含后加的 echo。
|
||||
const assistant = await runEventedToolLoop(
|
||||
state,
|
||||
provider,
|
||||
registry,
|
||||
hooks,
|
||||
snapshot,
|
||||
output,
|
||||
);
|
||||
output.log("");
|
||||
|
||||
printAssistantMessage(output, assistant);
|
||||
|
||||
output.log("[provider 看到的 tools]");
|
||||
output.log(`tools: ${provider.lastInput?.tools.length ?? 0}`);
|
||||
output.log("");
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
0
learn-pi-agent/s06_turn_snapshot/images/.gitkeep
Normal file
0
learn-pi-agent/s06_turn_snapshot/images/.gitkeep
Normal file
Reference in New Issue
Block a user