mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-06-23 05:33:37 +08:00
refactor: organize agent harness courses
This commit is contained in:
178
learn-pi-agent/s10_runtime_modes/README.md
Normal file
178
learn-pi-agent/s10_runtime_modes/README.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# s10: Runtime Modes — 同一个 core,不同的展示
|
||||
|
||||
> *core 只管产生,怎么展示外层说了算。*
|
||||
> **Pi 边界**:运行方式边界 —— core 产生事件,展示方式是外层的事,换展示不改 core。
|
||||
|
||||
[上一节:s09](../s09_extension_runtime/) → `s10` → [下一节:s11](../s11_trust_and_execution_boundary/)
|
||||
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
前面几节里,core 一产生结果就直接打印出来——展示方式写死在代码里。
|
||||
|
||||
但"展示"这件事,不同场景要的不一样:给人看,要人类可读的文本;给别的程序看,要结构化的 JSON;以后可能还要 GUI 渲染。如果展示方式写死在 core 里,每换一种就得复制或改动 core。
|
||||
|
||||
core 只该管**产生**什么,**怎么展示**应该分离出去。
|
||||
|
||||
---
|
||||
|
||||
## 解决方案
|
||||
|
||||
core 把要做的事变成一批 `RuntimeEvent`,外层用一个 `RuntimeMode` 决定怎么展示。
|
||||
|
||||
```text
|
||||
createDemoRuntimeEvents() → RuntimeEvent[] → RuntimeMode.render()
|
||||
```
|
||||
|
||||
同一个 core、同一批事件,接不同的 mode 就有不同输出:
|
||||
|
||||
| mode | 展示成 |
|
||||
| --- | --- |
|
||||
| `PrintMode` | 人类可读文本(只打印 message) |
|
||||
| `JsonMode` | 结构化 JSON(每事件一行) |
|
||||
|
||||
> **[R7 收获]** 回想 s01:那时候 core 不直接 `console.log`,而是走了一层 `Output.log`。那是一个最小的"输出抽象"种子。s10 把它正式化、可切换了——同一个 core 的事件,想打印就 PrintMode,想 JSON 就 JsonMode,core 一个字都不用改。
|
||||
|
||||
这里不是替换 s01-s09 的 `runEventedToolLoop`。为了让本节输出短一点,demo 用 `createDemoRuntimeEvents()` 造一批最小事件;真正的主线里,这批事件来自前面已经累积出来的 core。
|
||||
|
||||
---
|
||||
|
||||
## 工作原理
|
||||
|
||||
**先准备一批事件。** `createDemoRuntimeEvents` 把输入变成一批最小 RuntimeEvent。它只是本节的演示事件源,不是新的主循环。
|
||||
|
||||
```ts
|
||||
export function createDemoRuntimeEvents(input: string): RuntimeEvent[] {
|
||||
return [
|
||||
{ type: "message", content: `收到:${input}` },
|
||||
{ type: "done" },
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**mode 消费事件。** RuntimeMode 只有一个方法 `render`。PrintMode 挑出 message 打印文本;JsonMode 把每个事件序列化成 JSON。
|
||||
|
||||
```ts
|
||||
export type RuntimeMode = { render(events: RuntimeEvent[]): void };
|
||||
|
||||
export class PrintMode implements RuntimeMode {
|
||||
render(events) {
|
||||
for (const event of events) {
|
||||
if (event.type === "message") console.log(event.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class JsonMode implements RuntimeMode {
|
||||
render(events) {
|
||||
for (const event of events) console.log(JSON.stringify(event));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 这一节真正建立的是**运行方式边界**:core 产生事件,展示是外层 mode 的事。RuntimeEvent 是 core 对外的"输出语言",mode 是"翻译器"。换展示方式只是换 mode,core 不动——这正是 s01 那层 Output 抽象要长成的样子。
|
||||
|
||||
---
|
||||
|
||||
## 试一下
|
||||
|
||||
运行:
|
||||
|
||||
```sh
|
||||
npm run s10
|
||||
```
|
||||
|
||||
输出类似:
|
||||
|
||||
```text
|
||||
s10: Runtime Modes
|
||||
|
||||
[print mode]
|
||||
收到:你好,mini Pi
|
||||
|
||||
[json mode]
|
||||
{"type":"message","content":"收到:你好,mini Pi"}
|
||||
{"type":"done"}
|
||||
```
|
||||
|
||||
观察重点:两种输出来自**同一批事件**——`[print mode]` 只显示了 message 内容,`[json mode]` 把每个事件都序列化了,包括 `done`。
|
||||
|
||||
---
|
||||
|
||||
## 接入主线
|
||||
|
||||
s10 在 s09 上累积。相对 s09 的变更:
|
||||
|
||||
| 组件 | s09 | s10 |
|
||||
| --- | --- | --- |
|
||||
| 新增类/函数 | — | `createDemoRuntimeEvents`(演示事件源)/ `PrintMode` / `JsonMode` |
|
||||
| 新增类型 | — | `RuntimeMode` |
|
||||
| 输出抽象 | `Output.log`(s01 起,逐行) | `RuntimeMode.render`(可切换展示) |
|
||||
| 主循环 / `ProviderInput` | — | **不变**(纯新增,无 U1 升级) |
|
||||
|
||||
**焊接点**:前面主线产出的 `RuntimeEvent[]` 交给 `RuntimeMode.render`;本节 demo 只用 `createDemoRuntimeEvents` 代替真实事件源。`PrintMode` / `JsonMode` 各自 `render` 同一批事件,core 与展示彻底分开。
|
||||
|
||||
---
|
||||
|
||||
## 接下来
|
||||
|
||||
core 会接触本地项目:要加载项目资料,也可能要执行本地动作。这两件事的风险不一样,得分开管。
|
||||
|
||||
下一节会把"能不能加载资料"和"能不能执行动作"拆成两个独立的开关。
|
||||
|
||||
进入下一节:[s11](../s11_trust_and_execution_boundary/)。
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>Pi 源码溯源:四种 AppMode 和自动分流</summary>
|
||||
|
||||
教学版两种 mode(Print/Json)消费同一批事件。Pi 的 `packages/coding-agent` 有四种运行模式,按终端环境自动分流。
|
||||
|
||||
### 源码在哪
|
||||
|
||||
- `packages/coding-agent/src/cli/args.ts:10` — `AppMode` 类型
|
||||
- `packages/coding-agent/src/main.ts:98` — `resolveAppMode`(分流逻辑)
|
||||
- `packages/coding-agent/src/main.ts:768` — 各模式入口
|
||||
- `packages/coding-agent/src/modes/print-mode.ts` — print 模式
|
||||
|
||||
### 四种模式
|
||||
|
||||
```ts
|
||||
type AppMode = "interactive" | "print" | "json" | "rpc";
|
||||
```
|
||||
|
||||
| 模式 | 什么时候用 | 怎么输出 |
|
||||
| --- | --- | --- |
|
||||
| interactive | stdin 和 stdout 都是 TTY | TUI 差分渲染(`pi-tui`) |
|
||||
| print | `--print` 或管道输入 | 纯文本,跑完退出 |
|
||||
| json | `--mode json` | 结构化 JSON 事件流 |
|
||||
| rpc | `--mode rpc` | JSON-RPC 接口,给编辑器/工具集成 |
|
||||
|
||||
教学版的 PrintMode/JsonMode 是 print 和 json 两种的极简版。
|
||||
|
||||
### 自动分流
|
||||
|
||||
`resolveAppMode`(`main.ts:98`)的判定顺序:
|
||||
|
||||
```ts
|
||||
function resolveAppMode(parsed, stdinIsTTY, stdoutIsTTY): AppMode {
|
||||
if (parsed.mode === "rpc") return "rpc"; // 显式 rpc 最优先
|
||||
if (parsed.mode === "json") return "json"; // 显式 json
|
||||
if (parsed.print || !stdinIsTTY || !stdoutIsTTY) return "print"; // 管道自动 print
|
||||
return "interactive"; // 默认交互
|
||||
}
|
||||
```
|
||||
|
||||
关键设计:**管道自动降级到 print**。把 pi 接到管道(`echo hi | pi`)时,它检测到 stdin 不是 TTY,自动用 print 模式——不会傻乎乎起一个 TUI。教学版没有这个自动检测。
|
||||
|
||||
### TUI 用差分渲染
|
||||
|
||||
interactive 模式(`main.ts:770`)用 `@earendil-works/pi-tui`,这是个专门的终端 UI 库,做差分渲染(只重绘变化的部分)——流式输出时不会闪烁。教学版的 mode 只是 console.log,没有渲染层。
|
||||
|
||||
### 一句话
|
||||
|
||||
教学版的 RuntimeMode 立的是"core 产事件、外层决定展示"。Pi 把它坐实成四种 AppMode + 管道自动降级 + TUI 差分渲染。同一个 agent core,接 TTY 是交互式、接管道是 print、接工具是 json/rpc——core 一个字不用改。
|
||||
|
||||
</details>
|
||||
269
learn-pi-agent/s10_runtime_modes/code.ts
Normal file
269
learn-pi-agent/s10_runtime_modes/code.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
// s10: Runtime Modes — mini Pi 的第 10 版
|
||||
//
|
||||
// core 只产生事件,怎么展示由外层 mode 决定。[R7 收获] s01 的 Output 抽象,长成可切换的 RuntimeMode。
|
||||
// 词汇边界:本章新增 RuntimeMode / PrintMode / JsonMode / createDemoRuntimeEvents / render。
|
||||
// 关键:Output 保留(过程打印),RuntimeMode 新增(结果展示);同一个 core 产同一批事件,不同 mode 展示成不同形式。
|
||||
|
||||
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 起)——
|
||||
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;
|
||||
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 {
|
||||
if (!this.entries.has(entryId)) throw new Error(`unknown entry: ${entryId}`);
|
||||
this.activeLeafId = entryId;
|
||||
}
|
||||
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()]; }
|
||||
}
|
||||
export type AgentState = { session: SessionTree; model: string };
|
||||
|
||||
// —— 工具契约(s02 起)——
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// —— 上下文资源(s08 起)——
|
||||
export type ContextResource = { kind: "agents" | "skill" | "prompt"; name: string; content: string };
|
||||
export class ResourceLoader {
|
||||
constructor(private resources: ContextResource[]) {}
|
||||
load(): ContextResource[] { return this.resources.map((r) => ({ ...r })); }
|
||||
}
|
||||
// s08:资源组装进 systemPrompt(对齐 Pi buildSystemPrompt)
|
||||
export function buildSystemPrompt(resources: ContextResource[]): string {
|
||||
return resources.map((r) => `[${r.kind}:${r.name}]\n${r.content}`).join("\n\n");
|
||||
}
|
||||
|
||||
// —— provider 对外 ——
|
||||
export type ProviderMessage =
|
||||
| { role: "user" | "assistant"; content: string }
|
||||
| { role: "toolResult"; toolCallId: string; content: string };
|
||||
export type ProviderInput = { systemPrompt: string; 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>; }
|
||||
|
||||
// —— s01 起:输出抽象(R7。s10 会再加 RuntimeMode,两者并存)——
|
||||
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 起:一轮快照 ——
|
||||
export type TurnSnapshot = { systemPrompt: string; 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, loader: ResourceLoader): TurnSnapshot {
|
||||
return {
|
||||
systemPrompt: buildSystemPrompt(loader.load()),
|
||||
messages: toProviderMessages(state.session.currentPath()),
|
||||
tools: registry.getSpecs(),
|
||||
};
|
||||
}
|
||||
export function buildProviderInputFromSnapshot(snapshot: TurnSnapshot, state: AgentState): ProviderInput {
|
||||
return {
|
||||
systemPrompt: snapshot.systemPrompt,
|
||||
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 }; }
|
||||
|
||||
// ============ 工具循环(s04 起,保留不动)============
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ s09 起:扩展运行时 ============
|
||||
export type RuntimeEvent = { type: "message"; content: string } | { type: "done" }; // U2 全局唯一
|
||||
type EventHandler<T extends RuntimeEvent["type"]> = (event: Extract<RuntimeEvent, { type: T }>) => void;
|
||||
export type Command = { name: string; run: () => string };
|
||||
export type ExtensionAPI = {
|
||||
on<T extends RuntimeEvent["type"]>(type: T, handler: EventHandler<T>): void;
|
||||
registerTool(tool: Tool): void;
|
||||
registerCommand(command: Command): void;
|
||||
};
|
||||
export type Extension = (api: ExtensionAPI) => void;
|
||||
export class ExtensionRuntime {
|
||||
private commands = new Map<string, Command>();
|
||||
private handlers: { type: RuntimeEvent["type"]; handler: (event: RuntimeEvent) => void }[] = [];
|
||||
constructor(private registry: ToolRegistry) {}
|
||||
createApi(): ExtensionAPI {
|
||||
return {
|
||||
on: (type, handler) => { this.handlers.push({ type, handler: handler as (event: RuntimeEvent) => void }); },
|
||||
registerTool: (tool) => { this.registry.register(tool); },
|
||||
registerCommand: (command) => { this.commands.set(command.name, command); },
|
||||
};
|
||||
}
|
||||
use(extension: Extension): void { extension(this.createApi()); }
|
||||
emit(event: RuntimeEvent): void {
|
||||
for (const { type, handler } of this.handlers) if (type === event.type) handler(event);
|
||||
}
|
||||
runCommand(name: string): string {
|
||||
const command = this.commands.get(name);
|
||||
if (!command) return `unknown command: ${name}`;
|
||||
return command.run();
|
||||
}
|
||||
}
|
||||
|
||||
// ============ s10 新增 [R7 收获]:运行方式(输出分离)============
|
||||
|
||||
// 为了压缩本节 demo,只造一批最小 RuntimeEvent。
|
||||
// 它不是替换前面累积出来的 tool loop,只是演示 mode 如何消费同一批事件。
|
||||
export function createDemoRuntimeEvents(input: string): RuntimeEvent[] {
|
||||
return [
|
||||
{ type: "message", content: `收到:${input}` },
|
||||
{ type: "done" },
|
||||
];
|
||||
}
|
||||
|
||||
// 输出方式:消费同一批事件,展示成不同形式。
|
||||
export type RuntimeMode = {
|
||||
render(events: RuntimeEvent[]): void;
|
||||
};
|
||||
|
||||
// 人类可读:只打印 message 的内容。
|
||||
export class PrintMode implements RuntimeMode {
|
||||
render(events: RuntimeEvent[]): void {
|
||||
for (const event of events) {
|
||||
if (event.type === "message") {
|
||||
console.log(event.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 结构化:每个事件一行 JSON,给机器消费。
|
||||
export class JsonMode implements RuntimeMode {
|
||||
render(events: RuntimeEvent[]): void {
|
||||
for (const event of events) {
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 演示脚手架 ============
|
||||
|
||||
function main(): void {
|
||||
const events = createDemoRuntimeEvents("你好,mini Pi");
|
||||
|
||||
console.log("s10: Runtime Modes");
|
||||
console.log("");
|
||||
|
||||
console.log("[print mode]");
|
||||
new PrintMode().render(events);
|
||||
console.log("");
|
||||
|
||||
console.log("[json mode]");
|
||||
new JsonMode().render(events);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
0
learn-pi-agent/s10_runtime_modes/images/.gitkeep
Normal file
0
learn-pi-agent/s10_runtime_modes/images/.gitkeep
Normal file
Reference in New Issue
Block a user