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:
201
learn-pi-agent/s07_session_tree/README.md
Normal file
201
learn-pi-agent/s07_session_tree/README.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# s07: Session Tree — 历史不是一条线,是一棵树
|
||||
|
||||
> *走错了能回头,回头还能换条路。*
|
||||
> **Pi 边界**:会话历史边界 —— 历史存成一棵树,一轮输入只取当前这条路径。
|
||||
|
||||
[上一节:s06](../s06_turn_snapshot/) → `s07` → [下一节:s08](../s08_context_resources/)
|
||||
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
到 s06 为止,消息都存在一个数组里,只能一条线往后加:A → B → C。
|
||||
|
||||
但现实里经常想这样:走到 B,发现不太对,想退回 A 换个方向再试一次。数组做不到——它只会往后追加,没有"回到某个点、从那里分叉"。
|
||||
|
||||
s07 要让历史能**分叉**。
|
||||
|
||||
---
|
||||
|
||||
## 解决方案
|
||||
|
||||
给每条消息记一个 `parentId`,指向它的上一条。这样历史不再是一条线,而是一棵树:同一个节点可以长出多条分支。
|
||||
|
||||
```text
|
||||
user(方案A)
|
||||
/ \
|
||||
asst(A的回答) asst(改走方案B)
|
||||
```
|
||||
|
||||
`currentPath` 从当前位置一路回溯到根,得到**当前这条线**的消息序列——provider 拿到的还是线性的消息,对外完全没变。`moveTo` 切换当前位置,就能走到另一条分支。
|
||||
|
||||
注意:s07 不是让 provider 理解一棵树。树只存在于 core 内部,用来支持回到历史点再分叉;provider 仍然只看当前路径上的线性 messages。
|
||||
|
||||
> **[U1 升级]** `AgentState.messages`(数组)升级为 `SessionTree`。这是受控升级:数组没法表达分叉,所以是替换。但 `currentPath()` 仍产出线性 `AgentMessage[]`,ProviderInput 的构造方式一字不变——升级藏在 core 内部,不漏到外面。
|
||||
|
||||
---
|
||||
|
||||
## 工作原理
|
||||
|
||||
**先定义节点。** 一个节点就是一条消息,外加它在树里的位置。
|
||||
|
||||
```ts
|
||||
export type SessionEntry = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
message: AgentMessage;
|
||||
};
|
||||
```
|
||||
|
||||
**SessionTree 做三件事。** 追加、切换位置、读当前路径。
|
||||
|
||||
```ts
|
||||
export class SessionTree {
|
||||
private entries = new Map<string, SessionEntry>();
|
||||
private activeLeafId: string | null = null;
|
||||
private counter = 0; // 实例级:每个树独立计数
|
||||
|
||||
append(message: AgentMessage): SessionEntry {
|
||||
const entry = { id: `e${++this.counter}`, parentId: this.activeLeafId, message };
|
||||
this.entries.set(entry.id, entry);
|
||||
this.activeLeafId = entry.id;
|
||||
return entry;
|
||||
}
|
||||
|
||||
moveTo(entryId: string): void { /* 切换当前位置 */ }
|
||||
|
||||
currentPath(): AgentMessage[] {
|
||||
// 从 activeLeaf 一路回溯到根,反转,得到当前这条线
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`append` 总是接在当前位置后面;`moveTo` 把当前位置挪到任意已有节点(分叉的起点);`currentPath` 回溯出当前这条线。切位置不会删掉旧节点——它们还在树里,只是不在当前路径上。
|
||||
|
||||
**id 计数器是实例级的**(`this.counter`),不是全局变量。这样多个 SessionTree 互不干扰,也不会因为新建一个树就接着旧树的编号往后数。
|
||||
|
||||
**对外不变。** `createTurnSnapshot` 和 `buildProviderInputFromSnapshot` 现在从 `state.session.currentPath()` 取消息,但产出的还是线性的 ProviderMessage[]——provider 这边感觉不到 core 内部已经从数组换成了树。
|
||||
|
||||
> 这一节真正建立的是**会话历史边界**:历史在 core 内部是一棵树,但对一轮输入来说,它永远是"当前这条路径"的线性投影。后面 s08 会往输入里加项目资料,但"历史 = 当前路径"这条规矩,从这里立起来。
|
||||
|
||||
---
|
||||
|
||||
## 试一下
|
||||
|
||||
运行:
|
||||
|
||||
```sh
|
||||
npm run s07
|
||||
```
|
||||
|
||||
输出类似:
|
||||
|
||||
```text
|
||||
s07: Session Tree
|
||||
|
||||
[路径:方案 A]
|
||||
user: 方案 A
|
||||
assistant: A 的回答
|
||||
|
||||
[路径:方案 B]
|
||||
user: 方案 A
|
||||
assistant: 改走方案 B
|
||||
|
||||
[所有节点]
|
||||
e1 parent=null user: 方案 A
|
||||
e2 parent=e1 assistant: A 的回答
|
||||
e3 parent=e1 assistant: 改走方案 B
|
||||
```
|
||||
|
||||
观察重点:`e2` 和 `e3` 的 parent 都是 `e1`——从同一个节点分叉出两条路;切到方案 B 后,`[路径:方案 B]` 只含 `e1` 和 `e3`,不含 `e2`。
|
||||
|
||||
---
|
||||
|
||||
## 接入主线
|
||||
|
||||
s07 在 s06 上累积。相对 s06 的变更:
|
||||
|
||||
| 组件 | s06 | s07 |
|
||||
| --- | --- | --- |
|
||||
| `AgentState` | `{ messages: AgentMessage[] }` | **`{ session: SessionTree }`**(U1 升级) |
|
||||
| 新增类型 | — | `SessionEntry` / `SessionTree` |
|
||||
| 消息写入 | `state.messages.push(...)` | `state.session.append(...)` |
|
||||
| 消息读取 | `state.messages` | `state.session.currentPath()` |
|
||||
| `createTurnSnapshot` / `buildProviderInputFromSnapshot` | 从 `state.messages` 取 | 从 `state.session.currentPath()` 取 |
|
||||
| `ProviderInput` 构造 | — | **不变**(currentPath 产出线性消息) |
|
||||
|
||||
**焊接点**:消息读写全改为走 `state.session`;但 `currentPath()` 产出线性 `AgentMessage[]`,所以 ProviderInput / TurnSnapshot 的构造逻辑一字未动。U1 升级藏在 core 内部。
|
||||
|
||||
---
|
||||
|
||||
## 接下来
|
||||
|
||||
现在一轮输入里有:当前路径上的历史、工具说明、模型名。
|
||||
|
||||
下一节会再往输入里加一样东西——项目本身的资料(比如一份说明文档、一个可复用的提示词)。
|
||||
|
||||
进入下一节:[s08](../s08_context_resources/)。
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>Pi 源码溯源:持久化的 SessionTree 和 11 种 entry</summary>
|
||||
|
||||
教学版的 SessionTree 在内存里、只有 message entry。Pi 的 session 是**持久化**的树,节点有 **11 种类型**,远不止消息。
|
||||
|
||||
### 源码在哪
|
||||
|
||||
- `packages/agent/src/harness/types.ts:334` — SessionTreeEntry 联合类型
|
||||
- `packages/agent/src/harness/types.ts:409` — 11 种 entry
|
||||
- `packages/agent/src/harness/session/session.ts:82` — Session 实现
|
||||
- `packages/agent/src/harness/session/session.ts:246` — `moveTo`(分支)
|
||||
|
||||
### 不只是消息:11 种 entry
|
||||
|
||||
教学版的 SessionEntry 只有 `{ id, parentId, message }`。Pi 的 entry 是个大联合(`types.ts:409`):
|
||||
|
||||
```ts
|
||||
type SessionTreeEntry =
|
||||
| MessageEntry // 消息(教学版唯一有的)
|
||||
| ThinkingLevelChangeEntry // 改了推理强度
|
||||
| ModelChangeEntry // 换了模型
|
||||
| ActiveToolsChangeEntry // 启用/禁用了工具
|
||||
| CompactionEntry // 做了上下文压缩(s08 方向)
|
||||
| BranchSummaryEntry // 分支摘要
|
||||
| CustomEntry / CustomMessageEntry // 自定义内容
|
||||
| LabelEntry // 给某个节点打标签
|
||||
| SessionInfoEntry // 会话元信息
|
||||
| LeafEntry; // 当前活跃叶子
|
||||
```
|
||||
|
||||
历史不只记"说了什么",还记"中途换了什么"——换模型、换工具、压缩上下文都是树上的节点。这样回到任何一个历史点,能完整还原当时的配置。
|
||||
|
||||
### parentId + moveTo = 真分叉
|
||||
|
||||
每个 entry 都有 `parentId`(`types.ts:337`),`moveTo(entryId)`(`session.ts:246`)切换当前位置:
|
||||
|
||||
```ts
|
||||
async appendMessage(message) {
|
||||
return this.appendTypedEntry({
|
||||
type: "message", id: ...,
|
||||
parentId: await this.storage.getLeafId(), // 挂在当前叶子下
|
||||
timestamp: ..., message,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
新节点总挂在当前叶子下;`moveTo` 把叶子指针挪到任意历史节点,再 append 就长出一条新分支。和教学版的 SessionTree 一模一样的心智,但 Pi 的分支还能带 `BranchSummaryEntry` 记录"为什么岔出去"。
|
||||
|
||||
### 持久化,不是内存
|
||||
|
||||
教学版的 SessionTree 在内存里、进程退出就没了。Pi 的 session 走 `storage`(`session.ts`),落盘持久化——关掉重开能恢复,能跨会话。`LeafEntry` 专门跟踪"当前在哪条分支",持久化后重启能接上。
|
||||
|
||||
### 边界
|
||||
|
||||
`moveTo` 一个不存在的 id 抛 `SessionError`(教学版也抛错,一致)。分支不会删旧节点——它们留在树里,只是不在当前路径上。
|
||||
|
||||
### 一句话
|
||||
|
||||
教学版的 SessionTree 立的是"历史是树、能分叉、一轮输入取当前路径"。Pi 把它坐实成持久化的树 + 11 种 entry(消息/模型变更/工具变更/压缩/分支摘要…),parentId + moveTo 实现分叉。教学版只留 MessageEntry 和分支骨架。
|
||||
|
||||
</details>
|
||||
316
learn-pi-agent/s07_session_tree/code.ts
Normal file
316
learn-pi-agent/s07_session_tree/code.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
// s07: Session Tree — mini Pi 的第 7 版
|
||||
//
|
||||
// [U1 受控升级] AgentState.messages 从数组升级为 SessionTree:历史能分叉,一轮输入取当前路径。
|
||||
// 词汇边界:本章新增 SessionTree / SessionEntry / parentId / moveTo / currentPath / append / activeLeaf。
|
||||
// 关键:currentPath() 仍产出线性 AgentMessage[],ProviderInput 的构造方式不变;id 计数器是实例级(不跨实例累加)。
|
||||
|
||||
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;
|
||||
|
||||
// ============ s07 新增 [U1]:会话历史从数组变成树 ============
|
||||
|
||||
// 一个节点 = 一条消息 + 它在树里的位置。
|
||||
export type SessionEntry = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
message: AgentMessage;
|
||||
};
|
||||
|
||||
export class SessionTree {
|
||||
private entries = new Map<string, SessionEntry>();
|
||||
private activeLeafId: string | null = null;
|
||||
private counter = 0; // 实例级:每个 SessionTree 独立计数,不跨实例累加
|
||||
|
||||
append(message: AgentMessage): SessionEntry {
|
||||
const entry: SessionEntry = {
|
||||
id: `e${++this.counter}`,
|
||||
parentId: this.activeLeafId,
|
||||
message,
|
||||
};
|
||||
this.entries.set(entry.id, entry);
|
||||
this.activeLeafId = entry.id;
|
||||
return entry;
|
||||
}
|
||||
|
||||
// 切换当前位置到某个已有节点(分叉的起点)。不存在的 id 会抛错。
|
||||
moveTo(entryId: string): void {
|
||||
if (!this.entries.has(entryId)) {
|
||||
throw new Error(`unknown entry: ${entryId}`);
|
||||
}
|
||||
this.activeLeafId = entryId;
|
||||
}
|
||||
|
||||
// 从当前位置回溯到根,产出一条线性的消息序列。ProviderInput 就用它。
|
||||
currentPath(): AgentMessage[] {
|
||||
const path: AgentMessage[] = [];
|
||||
let cursor = this.activeLeafId;
|
||||
while (cursor) {
|
||||
const entry = this.entries.get(cursor);
|
||||
if (!entry) break;
|
||||
path.push(entry.message);
|
||||
cursor = entry.parentId;
|
||||
}
|
||||
return path.reverse();
|
||||
}
|
||||
|
||||
allEntries(): SessionEntry[] {
|
||||
return [...this.entries.values()];
|
||||
}
|
||||
}
|
||||
|
||||
// [U1] core 内部状态:messages 数组 → SessionTree;model 跨轮配置(对齐 Pi AgentState)。
|
||||
export type AgentState = {
|
||||
session: SessionTree;
|
||||
model: string;
|
||||
};
|
||||
|
||||
// —— 工具契约 ——
|
||||
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 对外 ——
|
||||
export type ProviderMessage =
|
||||
| { role: "user" | "assistant"; content: string }
|
||||
| { role: "toolResult"; toolCallId: string; content: string };
|
||||
|
||||
// 对齐 Pi Context:messages + tools。model 在 AgentState,不进 ProviderInput。
|
||||
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 在 state 不进快照)——
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
// s07:messages 从 state.session.currentPath() 取(线性投影当前路径)。
|
||||
export function createTurnSnapshot(
|
||||
state: AgentState,
|
||||
registry: ToolRegistry,
|
||||
): TurnSnapshot {
|
||||
return {
|
||||
messages: toProviderMessages(state.session.currentPath()),
|
||||
tools: registry.getSpecs(),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProviderInputFromSnapshot(
|
||||
snapshot: TurnSnapshot,
|
||||
state: AgentState,
|
||||
): ProviderInput {
|
||||
return {
|
||||
messages: toProviderMessages(state.session.currentPath()),
|
||||
tools: snapshot.tools,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 构造函数 ============
|
||||
export function createInitialState(model = "demo-small"): AgentState {
|
||||
return { session: new SessionTree(), model };
|
||||
}
|
||||
|
||||
export function createUserMessage(content: string): UserMessage {
|
||||
return { role: "user", content };
|
||||
}
|
||||
|
||||
// ============ 工具循环(s07:用 state.session)============
|
||||
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.session.append(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.session.append(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.session.append(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 printPath(output: Output, title: string, path: AgentMessage[]): void {
|
||||
output.log(title);
|
||||
for (const message of path) {
|
||||
output.log(`${message.role}: ${message.content}`);
|
||||
}
|
||||
output.log("");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const output = createConsoleOutput();
|
||||
const state = createInitialState();
|
||||
|
||||
output.log("s07: Session Tree");
|
||||
output.log("");
|
||||
|
||||
// 第一条线:方案 A
|
||||
const first = state.session.append(createUserMessage("方案 A"));
|
||||
state.session.append({
|
||||
role: "assistant",
|
||||
content: "A 的回答",
|
||||
stopReason: "stop",
|
||||
});
|
||||
|
||||
printPath(output, "[路径:方案 A]", state.session.currentPath());
|
||||
|
||||
// 回到第一个节点,从那里分叉出方案 B
|
||||
state.session.moveTo(first.id);
|
||||
state.session.append({
|
||||
role: "assistant",
|
||||
content: "改走方案 B",
|
||||
stopReason: "stop",
|
||||
});
|
||||
|
||||
printPath(output, "[路径:方案 B]", state.session.currentPath());
|
||||
|
||||
// 树的全貌
|
||||
output.log("[所有节点]");
|
||||
for (const entry of state.session.allEntries()) {
|
||||
output.log(
|
||||
`${entry.id} parent=${entry.parentId ?? "null"} ${entry.message.role}: ${entry.message.content}`,
|
||||
);
|
||||
}
|
||||
output.log("");
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
0
learn-pi-agent/s07_session_tree/images/.gitkeep
Normal file
0
learn-pi-agent/s07_session_tree/images/.gitkeep
Normal file
Reference in New Issue
Block a user