把教学版跑通以后,不要急着把它改成“什么都能做”的大项目。更稳的方式是沿着 Pi 的真实工程边界逐步扩展:模型适配层、工具权限层、事件流、会话树 UI 和失败保护。
本页给你工程草图,不要求一次性实现。每个方向都包含最小接口、关键状态、实现步骤和风险提示。
flowchart TB A["教学版 MockModel Agent"] --> B["provider adapter"] A --> C["tool permission hook"] A --> D["SSE streaming UI"] A --> E["session tree UI"] B --> F["真实模型 + 统一协议"] C --> G["工具确认 / 拦截 / 改写"] D --> H["增量消息和工具事件"] E --> I["分支导航和 branch summary"]
事实核对口径:
| Pi 真实能力 | 官方入口 | 教学版扩展时怎么映射 |
|---|---|---|
模型供应商差异隔离在 pi-ai |
SDK 与 packages/ai/src/providers/* |
新增 adapter,不把 OpenAI/Anthropic 分支写进 loop |
扩展可监听 tool_call / tool_result |
Extensions | 教学版可以先实现本地 beforeToolCall hook |
AgentSession.subscribe() 接收事件流 |
SDK | 后端用 SSE 推送 AgentEvent |
| 会话是 JSONL entry tree | Session Format | 前端按 id / parentId 渲染树 |
| 切分支可生成 branch summary | Compaction | 先做 UI 分支,再把 branch summary 作为进阶练习 |
接入真实模型时,最诱人的写法是在 runAgentLoop() 里判断:
if (provider === "openai") {
// parse tool_calls
} else if (provider === "anthropic") {
// parse tool_use
}
这个写法很快会失控。工具调用格式、流式 delta、错误结构、token usage、abort 语义都不一样,一旦它们进了 loop,核心闭环就会被 provider 细节污染。
更好的边界是:
export interface TeachingModel {
complete(input: {
systemPrompt: string;
messages: AgentMessage[];
tools: ToolDefinition[];
signal?: AbortSignal;
}): Promise<AssistantMessage>;
}
然后分别实现:
| 实现 | 职责 |
|---|---|
MockModel |
稳定、可测试、无网络依赖 |
OpenAICompatibleModel |
tool_calls / tool message / finish_reason 转教学版协议 |
AnthropicModel |
将 content blocks、tool_use、tool_result 转教学版协议 |
Pi 的 pi-ai 就是在做这件事:把 provider 输入输出整理成统一消息和事件,让 Agent Loop 只关心“assistant 是否请求工具、工具结果如何回写、什么时候停止”。
当前教学版已经抽出了 TeachingModel 接口,MockModel 只是这个接口的一个实现。
推荐你先读 可选:接入 OpenAI-compatible 模型,再考虑把 Demo 5 的 adapter 移进教学版后端。
Pi 真实扩展系统里,工具执行前后可以通过 tool_call、tool_result 等事件做干预。教学版可以先实现一个更小的 hook:beforeToolCall。
最小接口:
type ToolDecision =
| { action: "allow" }
| { action: "block"; reason: string }
| { action: "rewrite"; args: Record<string, unknown>; reason?: string }
| { action: "confirm"; prompt: string };
type BeforeToolCall = (call: {
id: string;
name: string;
arguments: Record<string, unknown>;
}) => Promise<ToolDecision>;
实现位置放在 executeToolCall() 前:
const decision = await beforeToolCall?.(toolCall);
if (decision?.action === "block") {
return errorToolResult(toolCall, decision.reason);
}
const args = decision?.action === "rewrite" ? decision.args : toolCall.arguments;
const result = await toolRegistry.execute(toolCall.name, args);
关键状态:
| 状态 | UI 怎么表现 |
|---|---|
allow |
直接执行工具 |
block |
展示被拦截原因,生成 isError toolResult |
rewrite |
展示原参数和改写后参数 |
confirm |
暂停 run,前端弹出确认,再继续 |
第一版可以只做 allow/block/rewrite,把 confirm 留到有 SSE 之后。因为确认需要后端 run 暂停、前端回复、再恢复执行,比普通同步 hook 多一个运行态。
当前教学版已经实现了 allow/block/rewrite 的最小版本:write_note 写入包含 secret 或 秘密 的文件名会被 block,list_files 缺少 path 时会被 rewrite 成 "."。
常见规则:
| 工具 | 规则示例 |
|---|---|
write_note |
文件名不能包含 secret、绝对路径和上级目录 |
read_file |
大文件先截断,敏感文件直接 block |
run_command |
rm -rf、curl | sh 默认 confirm 或 block |
web_fetch |
限制域名和响应大小 |
风险提示:权限 hook 不应该只在前端做。前端确认可以改善体验,但真正的拦截必须在后端工具执行前完成。
当前教学版已经实现两段式 SSE:浏览器先创建 run,再订阅该 run 的事件流。POST /api/prompt 仍然保留给 curl 和一次性 API 调试。
sequenceDiagram
participant UI as React UI
participant API as Express API
participant Loop as runAgentLoop
participant Model as Model Adapter
participant Tools as ToolRegistry
UI->>API: POST /api/runs { text }
API-->>UI: { runId }
UI->>API: GET /api/runs/:runId/events
API->>Loop: runAgentLoop(onEvent)
Loop->>Model: complete / stream
Model-->>Loop: message_update
Loop-->>API: AgentEvent
API-->>UI: SSE event
Loop->>Tools: execute tool
Tools-->>Loop: toolResult
Loop-->>API: tool_execution_end
API-->>UI: SSE event后端事件格式可以直接复用 AgentEvent:
function sendEvent(res: Response, event: AgentEvent) {
res.write(`event: ${event.type}\n`);
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
前端状态不要等最终消息才更新:
const source = new EventSource(`/api/runs/${runId}/events`);
source.addEventListener("message_update", (event) => {
const payload = JSON.parse(event.data) as AgentEvent;
appendDelta(payload);
});
source.addEventListener("tool_execution_start", (event) => {
markToolRunning(JSON.parse(event.data));
});
关键状态:
| 状态 | React 里建议怎么存 |
|---|---|
| 正在生成的 assistant message | draftAssistantById |
| 工具运行中 | runningTools: Record<toolCallId, ToolState> |
| 已完成消息 | messages |
| 错误或中止 | runStatus: "idle" | "running" | "error" | "aborted" |
风险提示:
| 风险 | 处理 |
|---|---|
| 浏览器断开 SSE | 后端监听 req.on("close"),触发 abort |
| 消息 delta 乱序 | 每个 message/toolCall 带稳定 id |
| 工具结果很大 | 后端先截断,再发 tool_execution_end |
| 事件重复 | 前端按 event id 或 message id 幂等更新 |
id / parentId / leafId 被看见教学版已经保存 id 和 parentId,但 UI 还只展示当前上下文。你可以做一个树视图,把 session 文件里的结构可视化。
最小 API:
GET /api/session
// 已有:返回 entries
POST /api/branch
// body: { leafId: string }
// effect: 切换当前 leaf,下一次 prompt 从该节点继续
前端构树:
type TreeNode = {
id: string;
label: string;
children: TreeNode[];
};
function buildTree(entries: SessionEntry[]): TreeNode[] {
const nodes = new Map<string, TreeNode>();
const roots: TreeNode[] = [];
// 1. 为 message / compaction entry 建节点
// 2. 按 parentId 挂到父节点
// 3. parentId 为空的节点进 roots
return roots;
}
UI 最小交互:
| 操作 | 后端行为 | 前端反馈 |
|---|---|---|
| 点击节点 | POST /api/branch |
高亮新的 leaf |
| 从节点继续提问 | 新 message 的 parentId 指向该 leaf |
树上长出新分支 |
| 切走长分支 | 可选生成 branch summary | 时间线显示 summary entry |
第一版不要急着做 branch summary。先确认“点击节点 -> 切 leaf -> 下一条 prompt 从该 leaf 生长”成立,再补摘要。这样读者能清楚区分 tree navigation 和 compaction。
Agent 工程的难点不在 happy path,而在这些边界:
| 失败模式 | 症状 | 最小保护 |
|---|---|---|
| 无限 tool call | 模型一直调用同一个工具 | maxTurns、预算、重复工具检测 |
| 工具输出过长 | 下一轮 prompt 爆上下文 | 工具结果截断、附件化、compaction |
| 模型不调用工具 | 用户要读文件,模型直接编 | system prompt 明确工具职责,UI 标出未使用工具 |
| 工具参数 JSON 错误 | provider 返回不可解析参数 | adapter 捕获并生成 isError toolResult |
| 工具不存在 | 工具名拼错或没注册 | registry 返回错误 toolResult |
| provider 超时/断流 | UI 一直 loading | AbortController、超时、stopReason: "aborted" |
| 用户中途取消 | 后端仍在跑工具 | 请求 close 时 abort 模型和工具 |
| 路径越界 | 模型传 ../secret |
后端 workspace guard |
| 多次提交并发 | 两个 run 同时写 session | per-session run lock 或队列 |
| 压缩摘要丢关键事实 | 后续模型忘了刚改的文件 | 保留最近消息、记录文件 read/write details |
这些保护不是“生产级才需要”。教学版已经实现了其中几条:maxTurns、未知工具变 isError、路径越界拦截、工具输出截断和简化 compaction。扩展时优先补你当前最容易踩的那一条。
TeachingModel 接口,让 MockModel 和真实 adapter 都实现它。(已在教学版实现)runAgentLoop() 加 beforeToolCall,先支持 allow/block/rewrite。(已在教学版实现)POST /api/prompt 改成 POST /api/runs + SSE events。(已在教学版实现,/api/prompt 保留为兼容接口)/api/branch,做最小会话树 UI。(已在教学版实现)当你完成这些扩展,再去读 Pi 真实源码会轻松很多。推荐顺序:
packages/ai/src/types.ts 与 packages/ai/src/providers/*packages/agent/src/agent-loop.tspackages/agent/src/agent.tspackages/coding-agent/src/core/agent-session.tspackages/coding-agent/src/core/extensions/packages/coding-agent/src/core/session-manager.tspackages/coding-agent/src/core/compaction/读的时候记住一个判断标准:如果某段代码是在隔离 provider 差异、保护工具边界、恢复 session 上下文或维持事件流,那它大概率不是“复杂化”,而是在替你挡真实 Agent 会遇到的麻烦。