refactor: organize agent harness courses

This commit is contained in:
Haoran
2026-06-16 00:10:35 +08:00
parent 20e7cbb72c
commit 8af5c24e46
491 changed files with 7961 additions and 564 deletions

View File

@@ -0,0 +1,200 @@
# s09: Extension Runtime — 外部代码通过 API 接入
> *core 不改,能力从外面接进来。*
> **Pi 边界**:扩展 API 边界 —— core 暴露的是 API不是内部对象。
[上一节s08](../s08_context_resources/) → `s09` → [下一节s10](../s10_runtime_modes/)
---
## 问题
到 s08 为止core 的能力(工具、资源)全都写在 core 代码里。每想加一种新玩法——一个新工具、一条新命令、对某类事件做个处理——都得改 core 自己。core 只会越来越重。
s09 要让**外部代码**接入 corecore 不用动就能长出新能力。
---
## 解决方案
core 暴露一个 `ExtensionAPI`,外部代码(叫一个 extension只能通过它做三件事
```text
on(type, handler) 订阅事件
registerTool(tool) 注册工具
registerCommand(command) 注册命令
```
一个 extension 就是一个接收 API 的函数。它拿不到 core 的内部对象,只能用这三个方法。
关键设计:`registerTool` 复用的是 s02 就有的 `Tool` 类型,注册进去的工具直接进**既有 ToolRegistry**。也就是说extension 注册的工具和 core 内置的工具,走的是**同一条执行链**(经过 s05 的 hook——不分彼此。
---
## 工作原理
**先定义事件和命令。** 事件是 core 往外发的信号;命令是外部注册的无参动作。
```ts
export type RuntimeEvent =
| { type: "message"; content: string }
| { type: "done" };
export type Command = { name: string; run: () => string };
```
**定义 API 表面。** 这就是 extension 能碰的全部。
```ts
export type ExtensionAPI = {
on(type: RuntimeEvent["type"], handler: (event: RuntimeEvent) => void): void;
registerTool(tool: Tool): void;
registerCommand(command: Command): void;
};
export type Extension = (api: ExtensionAPI) => void;
```
**ExtensionRuntime 接住注册。** 它构造时接收既有 registry`registerTool` 直接往这个 registry 里加。
```ts
export class ExtensionRuntime {
constructor(private registry: ToolRegistry) {}
createApi(): ExtensionAPI {
return {
on: (type, handler) => { this.handlers.push({ type, handler }); },
registerTool: (tool) => { this.registry.register(tool); }, // 注入既有 registry
registerCommand: (command) => { this.commands.set(command.name, command); },
};
}
emit(event) { /* 按类型匹配 handler不是全调 */ }
runCommand(name) { /* 找不到返回 unknown command */ }
}
```
两个细节:`emit` 按**事件类型**匹配 handler不是把所有 handler 都调一遍);命令找不到时返回一句说明,不抛错。
> 这一节真正建立的是**扩展 API 边界**core 对外只给一个受控的 APIextension 加的工具和内置工具同源同链,事件按类型分发。后面 s11 的权限检查会同样作用在 extension 注册的工具上,因为它们本就在同一个 registry 里。
---
## 试一下
运行:
```sh
npm run s09
```
输出类似:
```text
s09: Extension Runtime
[registry]
tool: current_time
tool: note
[event] message: hello from core
[command]
/status -> extension is active
[tool via extension]
note -> note saved: hi
```
观察重点:`[registry]``current_time` 是内置的、`note` 是 extension 注册的,两者同处一个 registry`[tool via extension]` 里 extension 的工具走的还是 `executeToolCall` 那条既有执行链。
---
## 接入主线
s09 在 s08 上累积。相对 s08 的变更:
| 组件 | s08 | s09 |
| --- | --- | --- |
| 新增类型 | — | `RuntimeEvent`U2 全局唯一)/ `Command` / `Extension` / `ExtensionAPI` |
| 新增类 | — | `ExtensionRuntime`(构造接收既有 `ToolRegistry` |
| 工具来源 | 只有 core 内置 | core 内置 + extension 注册(同一 registry |
| `ProviderInput` / 主循环 | — | **不变**(纯新增,无 U1 升级) |
**焊接点**`ExtensionRuntime` 构造接收既有 `ToolRegistry``registerTool` 往里加。extension 工具和内置工具同源,执行时都走 `executeToolCall`
---
## 接下来
现在 core 能产生结果但结果怎么展示打印JSON写死在代码里。
下一节会把"产生结果"和"展示结果"分开:同一个 core接不同的输出方式。
进入下一节:[s10](../s10_runtime_modes/)。
---
<details>
<summary>Pi 源码溯源Extension API 和它的 20 多个事件</summary>
教学版的 ExtensionAPI 暴露 on/registerTool/registerCommand 三个方法。Pi 的 `packages/coding-agent` 有一套庞大得多的 extension 系统。
### 源码在哪
- `packages/coding-agent/src/core/extensions/types.ts``ExtensionAPI` 类型
- `packages/coding-agent/src/core/extensions/loader.ts` — 发现 + 加载
- `packages/coding-agent/src/core/extensions/runner.ts` — 运行时
- `.pi/extensions/` — 项目级扩展目录
### API 比教学版大得多
教学版三个方法。Pi 的 `ExtensionAPI``types.ts`)有一长串:
```ts
interface ExtensionAPI {
// 注册能力
registerTool(tool): void;
registerCommand(name, options): void;
registerFlag(name, { description, type, default }): void;
// 订阅事件20+ 种)
on(event: "session_start" | "tool_execution_start" | "before_agent_start" | ..., handler): void;
// 运行时动作
sendMessage(msg): void;
setModel(model): void;
getActiveTools(): AgentTool[];
registerProvider(...) / unregisterProvider(...): void;
exec(command): Promise<...>;
}
```
教学版的 on/registerTool/registerCommand 是它的一个子集。Pi 的 extension 不仅能加工具/命令,还能改模型、注册 provider、执行命令、订阅 20 多种生命周期事件。
### 20 多种事件
教学版只有 `message` / `done` 两种 RuntimeEvent。Pi 的 extension 能订阅 `session_start``tool_execution_start``before_agent_start``project_trust`s11 用它决定信任)……覆盖整个 agent 生命周期。每个事件的 handler 还能返回结果反向影响 core比如 `before_agent_start` 的返回值能改本轮配置)。
### 四种发现来源
`discoverAndLoadExtensions``loader.ts:557`)从四个地方找扩展:
```text
1. cwd/.pi/extensions/ 项目级
2. agentDir/.pi/extensions/ 全局级
3. package.json 的 pi.extensions 字段 包声明
4. 命令行传入的路径 CLI 级
```
教学版的 extension 是手动 `runtime.use(...)`。Pi 是自动发现——放对目录就加载。
### 冲突检测 + 沙箱
两个扩展注册同名工具怎么办?`detectExtensionConflicts``loader.ts:988`)检查工具/命令/标志名冲突,通过 `ResourceDiagnostic` 报告,保留先加载的。扩展代码跑在 jiti 沙箱里,每个扩展有 `sourceInfo` 标记来源和权限级别——这是教学版完全没有的隔离层。
### notInitialized 守卫
`createExtensionRuntime``runner.ts`)有个巧思:扩展加载阶段(执行 factory 函数时runtime 的动作方法sendMessage 等)都指向 `notInitialized`——一调用就抛错。因为加载时 core 还没就绪,扩展只能"注册",不能"动作"。加载完成后才换上真实实现。
### 一句话
教学版的 ExtensionAPI 立的是"外部代码通过受控 API 接入 core"。Pi 把它坐实成 20 多个事件 + 注册 tool/command/flag/provider + 四种自动发现 + 冲突检测 + 沙箱隔离。教学版只保留最小接入on/registerTool/registerCommand + 手动 use但"core 暴露 API 而非内部"这条边界一致。
</details>

View File

@@ -0,0 +1,334 @@
// s09: Extension Runtime — mini Pi 的第 9 版
//
// 外部代码通过公开 API 接入 core订阅事件、注册工具、注册命令。core 不用动就能长出新能力。
// 词汇边界:本章新增 Extension / ExtensionAPI / ExtensionRuntime / Command / RuntimeEvent / on / registerTool / registerCommand / emit / use。
// 关键registerTool 复用既有 Tool 类型,注入现有 ToolRegistry——extension 的工具和内置工具走同一条执行链。
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>; }
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 }; }
// ============ 工具循环(不变)============
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 新增:扩展运行时 ============
// U2 全局唯一s09 定义s10 复用。
export type RuntimeEvent =
| { type: "message"; content: string }
| { type: "done" };
// 命令:一个不带参数、返回字符串的动作。
export type Command = { name: string; run: () => string };
// 订阅某类事件时handler 收到的事件结构自动对应(订阅 "message" 就只收 message 事件)。
type EventHandler<T extends RuntimeEvent["type"]> = (
event: Extract<RuntimeEvent, { type: T }>,
) => void;
// extension 能接触的全部表面。
export type ExtensionAPI = {
on<T extends RuntimeEvent["type"]>(type: T, handler: EventHandler<T>): void;
registerTool(tool: Tool): void; // 复用 s02 的 Tool
registerCommand(command: Command): void;
};
// 一个 extension 就是一个接收 API 的函数。
export type Extension = (api: ExtensionAPI) => void;
export class ExtensionRuntime {
private registry: ToolRegistry; // 复用既有 registryextension 注册的工具和内置工具同源
private commands = new Map<string, Command>();
private handlers: { type: RuntimeEvent["type"]; handler: (event: RuntimeEvent) => void }[] = [];
constructor(registry: ToolRegistry) {
this.registry = registry;
}
// 外部只能拿到这个 API拿不到 runtime 内部字段。
createApi(): ExtensionAPI {
return {
on: (type, handler) => {
this.handlers.push({
type,
handler: handler as (event: RuntimeEvent) => void,
});
},
registerTool: (tool) => {
this.registry.register(tool); // 注入既有 registry走同一执行链
},
registerCommand: (command) => {
this.commands.set(command.name, command);
},
};
}
use(extension: Extension): void {
extension(this.createApi());
}
// 按事件类型分发(不是全部 handler 都调)。
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();
}
}
// ============ Demo Provider保留累积============
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;
}
// 一个 demo extension订阅事件、注册命令、注册工具。全部通过 API不碰 core 内部。
function createDemoExtension(output: Output): Extension {
return (api) => {
api.on("message", (event) => {
output.log(`[event] message: ${event.content}`);
});
api.registerCommand({ name: "status", run: () => "extension is active" });
api.registerTool({
spec: { name: "note", description: "保存一条笔记", input: { text: "内容" } },
handler: (input) => `note saved: ${input.text ?? ""}`,
});
};
}
async function main(): Promise<void> {
const output = createConsoleOutput();
const registry = createRegistry();
const runtime = new ExtensionRuntime(registry);
output.log("s09: Extension Runtime");
output.log("");
// extension 接入:通过 API 注册能力。
runtime.use(createDemoExtension(output));
// 注册后registry 里既有内置工具,也有 extension 注册的工具。
output.log("[registry]");
for (const spec of registry.getSpecs()) {
output.log(`tool: ${spec.name}`);
}
output.log("");
// 事件core emitextension 的 handler 被触发(按类型匹配)。
runtime.emit({ type: "message", content: "hello from core" });
output.log("");
// 命令。
output.log("[command]");
output.log(`/status -> ${runtime.runCommand("status")}`);
output.log("");
// extension 注册的工具走既有执行链executeToolCall
output.log("[tool via extension]");
const result = executeToolCall(
registry,
{},
{ id: "c1", name: "note", input: { text: "hi" } },
);
output.log(`note -> ${result.content}`);
output.log("");
}
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});