# 累积演进宪法 > 本文件是 `learn-pi-agent` 的工程对照基准。每一节的 `code.ts` 和 `README.md` 都必须对照它。 > 目标:学习者从 s01 学到 s12,**累积**实现出一个机制健全、心智与 Pi 一致的 mini Pi。 --- ## 0. 核心立场 现有课程确立了「每节独立、最小化」(P2),但丢了「累积实现」(P0)。 本宪法把 P0 立起来,同时不毁掉「每节聚焦一个机制」的教学性。 **一句话原则:每一节 = 给正在生长的 mini Pi 装一个零件。** - 代码是单轨累积的:`sXX/code.ts` 是 mini Pi 的第 N 个版本,是第 N-1 节的**超集**。 - 文档负责聚焦:每节 README 末尾的「接入主线」段用 diff 风格展示本节焊上了什么零件。 不维护「聚焦 demo」和「主线」两份代码。两份代码必然漂移,漂移就是现在所有类型退化问题的根源。 --- ## 1. 元规则(约束所有类型演化) | 规则 | 内容 | 修复的现有问题 | |---|---|---| | **R1** | 字段**只增不删**:`ProviderInput`、`ProviderEvent`、`StopReason` 取值集、`AgentMessage` union 成员、`ToolSpec`、`TurnSnapshot` 字段,一旦引入永久保留 | s03 删 tools、s04 删 message_start、s06/s08 删 stopReason、s04/s06 删 ToolSpec.input | | **R2** | 方法**只增**:`ToolRegistry` 等类的成员只增 | registry 在 s02/s04/s06 间反复变方法集 | | **R3** | 只允许两类**受控升级**,且必须在当节 README 显式声明「这是升级,不是新增」:
**U1 接口语义升级**(不可避免的 breaking change)
**U2 同名类型全局唯一** | 现有「同名不同义」「5 节 5 种 Tool 形状」 | | **R4** | 错误**不崩溃**,转结构化消息(见 §4) | Gap3 错误传播缺失 | | **R5** | 循环有**终止保证**(`maxTurns` 上限) | Gap3 工具循环无保护 | | **R6** | **加载**和**执行**分开管(对齐 Pi 真实设计):trust 控制资源加载;执行边界不内置、靠部署层 containerization;hook 是唯一的执行细化拦截点 | Gap3 trust/policy/hook 三个孤立 demo | | **R7** | core 通过 **output 抽象**输出,不直接 `console.log`。从 s01 起就有最小间接层 | Gap3 输出/执行分离,s10 才分离却已焊死 | | **R8** | 每节 `code.ts` 是前一节的超集;README「接入主线」段以 diff 展示增量 | 不累积 | ### U1 受控升级清单(宪法允许的全部 breaking change) | 节 | 升级 | 理由 | |---|---|---| | s03 | `Provider.complete()` → `Provider.stream()` | provider 输出形态本质改变(一次性→流式),无法并存 | | s07 | `AgentState.messages: AgentMessage[]` → `SessionTree` | 历史从线性升级为可分支树;`currentPath()` 仍产出 `AgentMessage[]`,对外构造方式不变 | | s11 | `ResourceLoader.load()` → `load(trust)` | 加信任参数过滤资源(参数升级) | 除以上三处外,**任何**对已有类型/接口的删改都违反宪法。 ### U2 同名类型全局唯一清单 `ResourceLoader`、`RuntimeEvent`、`Tool`、`Output` 在主线中各自**只有一个定义**,所有章节复用它。 --- ## 2. 核心类型字典 > 终态定义 + 引入节 + 演变。每节 `code.ts` 必须与字典一致。 ```ts // —— 消息(s01 起,union 只增 R1)—— type StopReason = "stop" | "error"; // s01 // = "stop" | "toolUse" | "error"; // s04 起加 toolUse,之后稳定 type UserMessage = { role: "user"; content: string }; // s01,稳定 type AssistantMessage = { role: "assistant"; content: string; stopReason: StopReason };// s01,stopReason 永驻 type ToolResultMessage= { role: "toolResult"; toolCallId: string; content: string }; // s04,稳定 type AgentMessage = UserMessage | AssistantMessage // s01 | ToolResultMessage; // s04 起并入(只增) // —— core 状态(对齐 Pi AgentState)—— type AgentState = { messages: AgentMessage[]; // s01 起;s07 升级为 SessionTree(U1) model: string; // s06 起加:跨轮配置,对齐 Pi(不在 ProviderInput/snapshot) }; // —— Provider 对外形态(对齐 Pi Context)—— type ProviderMessage = | { role: "user" | "assistant"; content: string } // s01 | { role: "toolResult"; toolCallId: string; content: string };// s04 起并入(只增) // Pi 的 Context = { systemPrompt?, messages, tools }。教学版对齐:systemPrompt(s08 起) + messages + tools。model 不在这里,在 AgentState。 type ProviderInput = { systemPrompt: string; // s08 起加(项目资料组装进去) messages: ProviderMessage[]; // s01 tools: ToolSpec[]; // s02 起加(s03 不许删) }; // —— 工具契约(s02 起,全局唯一形状 U2)—— type ToolSpec = { name: string; description: string; input: Record };// input 永驻 type ToolHandler = (input: Record) => string; // 同步;抛错由 R4 捕获 type Tool = { spec: ToolSpec; handler: ToolHandler }; // 全局唯一形状 type ToolCall = { id: string; name: string; input: Record }; // s04 class ToolRegistry { // 方法只增 R2 register(tool: Tool): void {} // s02 getSpecs(): ToolSpec[] {} // s02 run(call: ToolCall): string {} // s04 } // —— Provider 事件流 —— type ProviderEvent = | { type: "message_start" } // s03(s04 不许删) | { type: "text_delta"; text: string } // s03 | { type: "message_end"; stopReason: StopReason } // s03 | { type: "tool_call"; call: ToolCall }; // s04 起加 interface Provider { // U1:s03 由 complete 升级为 stream stream(input: ProviderInput): AsyncGenerator; } // —— Turn 快照(对齐 Pi AgentContext:固定 systemPrompt/messages/tools;model 在 state 不进快照)—— type TurnSnapshot = { systemPrompt: string; messages: ProviderMessage[]; tools: ToolSpec[] }; // —— 会话树(U1:s07 取代扁平 messages 数组)—— type SessionEntry = { id: string; parentId: string | null; role: "user" | "assistant" | "toolResult"; content: string }; class SessionTree { append(msg): SessionEntry {} moveTo(id: string): void {} currentPath(): AgentMessage[] {} // 产出线性消息供 ProviderInput 使用 allEntries(): SessionEntry[] {} } // —— 上下文资源(s08,U2 全局唯一 ResourceLoader)—— type ContextResource = { kind: "agents" | "skill" | "prompt"; name: string; content: string }; class ResourceLoader { constructor(private resources: ContextResource[]) {} load(trust?: ProjectTrust): ContextResource[] {} // U1:s11 加 trust 参数 } // s08:资源组装进 systemPrompt(对齐 Pi buildSystemPrompt),不是独立 context 字段 function buildSystemPrompt(resources: ContextResource[]): string {} // —— Hook(s05,外层装饰 registry.run,不进 registry)—— type BeforeToolCallResult = { type: "allow" } | { type: "block"; reason: string }; type ToolHooks = { beforeToolCall?: (call: ToolCall) => BeforeToolCallResult; afterToolCall?: (call: ToolCall, result: string) => string; }; function executeToolCall(registry, hooks, call): ToolResultMessage {} // s05 起,稳定(不加 policy) // —— 扩展运行时(s09,复用前面的 Tool/ToolRegistry)—— type Command = { name: string; run: () => string }; type RuntimeEvent = { type: "message"; content: string } | { type: "done" }; // U2 全局唯一 type ExtensionAPI = { on(type: RuntimeEvent["type"], handler: (e: RuntimeEvent) => void): void; registerTool(tool: Tool): void; // 复用 s02 的 Tool,注入现有 ToolRegistry registerCommand(cmd: Command): void; }; type Extension = (api: ExtensionAPI) => void; class ExtensionRuntime { use(ext: Extension): void; /* 内部持有 ToolRegistry */ } // —— 输出抽象(R7:s01 最小形态 → s10 正式化)—— type Output = { log(line: string): void }; // s01 起最小间接层 // s10 升级为: type RuntimeMode = { render(events: RuntimeEvent[]): void }; // PrintMode / JsonMode 是两个实现 // —— 信任与执行边界(s11,对齐 Pi:trust 控加载,执行靠 containerization)—— type ProjectTrust = "trusted" | "untrusted"; // 注:Pi 不内置执行权限系统。ExecutionPolicy/Executor 已移除——执行边界靠部署层 // containerization 三方案(OpenShell / Gondolin / Plain Docker),core 内只有 trust 控制资源加载。 // —— 能力分发(s12,注入既有 registry/commands/loader)—— type PackageManifest = { name: string; tools: string[]; commands: string[]; resources: string[] }; type Package = { manifest: PackageManifest; contents: Record }; type LoadedPackage = { name: string; tools: Record; commands: Record; resources: Record }; function loadPackage(pkg: Package): LoadedPackage {} ``` --- ## 3. 十二节累积演进主表 > 每节三栏:**累积骨架(不变)** | **本节新增零件** | **接入点(焊在哪)** | 节 | 累积骨架(不变) | 本节新增零件 | 接入点 | |---|---|---|---| | **s01** | — | AgentState、消息三类型、StopReason(stop/error)、ProviderInput{messages}、Provider.complete、runOneTurn、`Output.log`(R7) | 地基 | | **s02** | messages、provider、runOneTurn | ToolSpec{name;description;**input**}、ToolHandler、Tool、ToolRegistry(register/getSpecs) | buildProviderInput 接收 registry;ProviderInput 加 `tools=registry.getSpecs()` | | **s03** | 全部(**含 tools,R1 不删**) | **[U1]** Provider complete→stream、ProviderEvent(message_start/text_delta/message_end)、collectAssistantMessage | Provider 接口升级;runOneTurn 内 complete→stream+collect | | **s04** | 事件流、tools | ToolCall、ToolResultMessage、tool_call 事件、StopReason+toolUse、ToolRegistry.run、runEventedToolLoop(**maxTurns** R5)、错误捕获(R4) | 循环内 `registry.run(call)`,结果入 messages;tools 仍取 `registry.getSpecs()`(**不硬编码**) | | **s05** | 工具循环 | ToolHooks、beforeToolCall/afterToolCall、BeforeToolCallResult(allow/block)、executeToolCall(registry,hooks,call) | 循环内 `registry.run(call)` → `executeToolCall(registry,hooks,call)`;registry 不变(R2) | | **s06** | 循环+hook | TurnSnapshot{messages,tools}、createTurnSnapshot、**AgentState+model**(跨轮配置,对齐 Pi) | runEventedToolLoop 开头先 createTurnSnapshot;model 在 AgentState 不进 snapshot(对齐 Pi AgentContext,snapshot 只固定 messages/tools) | | **s07** | snapshot、循环、hook | **[U1]** messages 数组→SessionTree、SessionEntry{parentId}、append/moveTo/currentPath | buildProviderInput 用 `session.currentPath()` 取线性消息;对外构造不变 | | **s08** | tree、snapshot、tools | ContextResource、ResourceLoader.load()、buildSystemPrompt、ProviderInput+**systemPrompt**(资料组装进去,对齐 Pi;tools 保留 R1) | buildProviderInput 调 buildSystemPrompt(loader.load()) 拼 systemPrompt;snapshot 跟随加 systemPrompt | | **s09** | 全部主线 | Extension、ExtensionAPI、ExtensionRuntime、Command、RuntimeEvent、on/registerTool/registerCommand | ExtensionRuntime 内部持有现有 ToolRegistry;registerTool 注入的 Tool 走同一执行链(s05 hook) | | **s10** | 全部主线 | **[R7 收获]** Output.log → RuntimeMode、PrintMode/JsonMode、render(RuntimeEvent[]) | core 的 `output.log` 升级为 `mode.render(events)`;s01 起就没直连 console,此处只是命名+多态化 | | **s11** | 全部主线 | ProjectTrust、**[U1]** load(trust)、containerization 三方案(执行边界靠部署层,对齐 Pi) | trust→`loader.load(trust)` 控制资源加载;执行边界不内置,靠 containerization(README 讲 OpenShell/Gondolin/Docker 三方案) | | **s12** | 全部主线 | PackageManifest、Package、LoadedPackage、loadPackage、pick | loadPackage 产出注入 registry(s02)/commands(s09)/loader(s08)。能力分发闭环 | --- ## 4. Gap3 健全心智补全(宪法硬规定) | 缺口 | 宪法规定 | |---|---| | **循环终止** (R5) | `runEventedToolLoop` 加 `maxTurns`(默认 **8**)。终止条件 = provider 不再发 tool_call **或** stopReason≠toolUse **或** 达上限。达上限时返回 stopReason=`"stop"` 并附注 "max turns reached" | | **错误传播** (R4) | ① provider stream 抛错 → 捕获,本轮 AssistantMessage.stopReason=`"error"`、content=错误说明,写回 state。
② tool handler 抛错 → `executeToolCall` 捕获,ToolResultMessage.content=`"error: "`,**循环继续**(让 provider 看到错误自行决定)。
StopReason 维持三值 `stop|toolUse|error`,不为错误新增类型 | | **加载/执行分离** (R6) | 两件事分开(对齐 Pi 真实设计):
• **加载**靠 trust:`loader.load(trust)`,untrusted 返回空(防恶意资源)
• **执行**不内置权限:Pi 不在 core 里限制文件/进程/网络,执行边界靠部署层 containerization 三方案(OpenShell / Gondolin / Plain Docker)
• **细化拦截**靠 hook:beforeToolCall allow/block 具体工具(core 内唯一的执行拦截点)
教学版不再发明 ExecutionPolicy——它在 Pi 里没有对应物 | | **systemPrompt 层级** | `ProviderInput.systemPrompt` 是 **system 级**装配(项目资料组装进去),与 `messages`(**对话级**历史)是两个独立维度。对齐 Pi 的 `Context.systemPrompt`(资料进 systemPrompt,不是独立 context 字段) | | **输出/执行分离** (R7) | 从 s01 起 core 通过 `Output.log` 输出,不直连 console;s10 升级为 `RuntimeMode.render`。前 9 节就不存在"core 与 IO 焊死"的债 | --- ## 5. 完整 Turn 执行链(总装蓝图) > 这是根 `README.md`(总装章)的灵魂。一条链接上全部 12 节 + Gap3。 ```text newTurn(userInput): 1. systemPrompt = buildSystemPrompt(loader.load(trust)) # s08 资源组装 + s11 trust 过滤 (R6) 2. session.append({role:"user", content:userInput}) # s07 历史树 (U1) 3. snapshot = createTurnSnapshot( # s06 快照 session.currentPath(), registry.getSpecs(), systemPrompt) # model 在 AgentState,不进 snapshot(对齐 Pi AgentContext) 4. for turn in 0..maxTurns: # s04 循环 (R5) input = buildProviderInput(snapshot, state) # s01/s02/s08;systemPrompt 从 snapshot,model 从 state events = provider.stream(input) # s03 事件流 (U1)(Pi 真实 stream(model,context),教学 fake provider 简化) for event in events: message_start / text_delta → 累加 content message_end → stopReason tool_call → result = executeToolCall( # s05 hook(R4 错误捕获) registry, hooks, call) # 无 policy(执行边界靠 containerization,R6) session.append(toolResultMessage) # s07 if 无 tool_call 或 stopReason≠toolUse: break 5. assistant = {role:"assistant", content, stopReason} # stopReason 永驻 (R1) 6. session.append(assistant) # s07 7. mode.render(allEvents) # s10 输出分离 (R7) 旁路:extension(s09) 可注册额外 tool/command; package(s12) 可分发 tool/resource 注入 registry/loader。 ``` 每一步都能追溯到某一节 + 某条规则。这就是「心智健全」的可验证证据。 --- ## 6. 文件结构与每节 README 规范 ### 6.1 文件结构 ```text learn-pi-agent/ README.md 总装章:执行链全景图(§5)+ 累积演进表索引(§3)+ 如何从 s01 读到 s12 EVOLUTION.md 本文件(宪法) sXX_*/ code.ts 累积主线第 N 版(前一节的超集,R8) README.md 按下方规范 ``` ### 6.2 每节 README 模板 ```text # sXX: <主题> — <一句话点题> > > Pi 边界:<本节对应的设计边界> ## 问题 ← 真痛点 / 上一节方案的缺陷(不提前命名下节概念) ## 解决方案 ← 核心洞察 + 设计取舍表(不重复工作原理步骤) ## 工作原理 ← 递进叙事:每步「为什么需要 + 代码」,末尾点睛 ## 试一下 ← 运行命令 + 输出 + 观察重点 ## 接入主线 ← 相对上节的变更表(前后对比)+ 焊接点 ## 接下来 ← 引出下一节痛点(只描述,不命名) ## Pi 源码溯源 ← 折叠,只对照本章词汇 ``` ### 6.3 写作规则(硬约束) - **R-写①「问题」必须有痛或张力**:场景痛点 或 上一节方案的缺陷。不准写任务描述("core 要保存对话"这种不算问题)。 - **R-写②「解决方案」是洞察不是目录**:一句话说清本质 + 设计取舍表。不准预告实现步骤(那是「工作原理」的事)。 - **R-写③「工作原理」是叙事不是清单**:每个类型/函数出场时回答"为什么现在需要它",递进展开,末尾必点睛(这一节真正交付的东西是什么)。U1 升级必须显式标注「这是升级」。 - **R-写④ 用自然中文,不直译英文意象**:motto 若源自英文,中文必须重写(`catch one turn` ✗ → 不译成"接住一轮");不为对仗生造动词搭配("穿过 core""接住一轮");用程序员实际会说的词("存下来""转成")。每节写完通读一遍,读起来"像翻译"的句子都要改。 - **R-写⑤ 词汇纪律(叙事段)**:「问题」「解决方案」「工作原理」「接下来」只用本章已解释词汇;不提前命名下节概念;"本章不讲 X"也把 X 引进来了,不要写。源码溯源同样控词。 - **例外**:「接入主线」段是工程对照表,允许路标式提及未来章节的术语,但必须标注出现章节(如 `ToolResultMessage(s04 加)`),作为前瞻路标,不是教学展开。 ### 6.4「接入主线」段的写法 用**变更表**(组件维度,前节 vs 本节),再加一句**焊接点**(本节零件焊在主线的哪个位置)。s01 作为地基节无前节可比,改列「本节确立的永驻基础 + 后续怎么演化」。后续节严格用「变更表 + 焊接点」。 --- ## 7. 已落实的修正 - **R1**:s03 保留 tools;s04 保留 message_start、ToolSpec.input,并从 registry.getSpecs() 取 tools;s06/s08 保留 stopReason;s08 的 systemPrompt 与 tools 并列进入 ProviderInput。 - **R2**:s04 之后的 ToolRegistry 统一为 `register + getSpecs + run`。 - **U2**:ResourceLoader、RuntimeEvent、Tool 全链路保持单一形状;后续章节复用同名类型,不另起含义。 - **U1**:s03、s07、s11 的三处受控升级在 README 的「接入主线」中显式标注。 - **R4/R5/R6/R7**:工具错误转 ToolResultMessage、工具循环有 maxTurns、trust 只控加载、输出从 s01 起经过 Output 并在 s10 长成 RuntimeMode。 - **R8**:每节 code.ts 是前一节的超集;每节 README 都有「接入主线」段。 - **幽灵名词**:`toProviderMessage`、`ContextBlock` 这类文档里出现但代码里没有的名词已移除或落到实际实现上。 ## 8. 待继续检查的点 - **s10 事件源**:本节按教学 A 方案使用 `createDemoRuntimeEvents()` 作为最小事件源,避免重讲完整 tool loop。后续如果读者仍误解为另起 core,再把「演示事件源」说明前移到「问题」段。 - **s12 安装闭环**:本节按教学 A 方案保留 `tools / commands / resources` 三类教学对象,并通过 `installLoadedPackage()` 接回主线。真实 Pi 的 `extensions / skills / prompts / themes` 只在源码溯源里讲,不进入教学主线。 - **s06/s07 心智负担**:snapshot 与 SessionTree 是课程里的两次陡坡,后续润色时优先看这两节的「问题」段是否足够具体。