这一页是什么
课程的 15 章基于 Pi 1.0 重建了 system 消息模型:system prompt 成为 transcript 里的一条消息,之后的修改以补丁消息的形式追加。下面几套机制 Pi 1.0 都有,课程没有实现, 也不会进入任何 checkpoint。建议读完第 13 章之后再看,那时 Runtime、会话和 system 补丁都已经在你手里跑过一遍。
所有引用固定在上游 v1.0.0(a13d35a742c6),写成 packages/…:行号,点开即是该提交下的文件。 虚拟模型在上游 CHANGELOG 里标为 experimental,pi-durable 的 README 标着 Experimental, 这两部分的接口可能在后续版本变化。
system 消息模型总览
Pi 1.0 处理“模型应该看到什么说明”时遵守一条习惯:只追加,不改写前缀。 工具集合或 prompt 的某个段落变了,Pi 不回头修改历史里那条声明,而是在对话末尾追加一条带补丁的 system 消息。前缀因此一直不变,provider 的 prompt cache 可以持续命中;按顺序重放所有 system 消息,就能得到当前生效的 prompt(packages/ai/src/utils/transcript.ts:73-105)。
[0] system content: "You are Pi …"
sections: { "pi-resources": "…" }
[1] user 读一下 README
[2] assistant toolCall read(README.md)
[3] toolResult …
[4] assistant README 讲的是……
[5] system content: ""
sections: { "mcp_servers": "…" } ← 本轮补丁
[6] user 接着看 package.json上面第 [5] 条只携带变化的段落。sections 里的值按名字覆盖旧段落,null 表示删除;content 非空时追加到已有说明之后。 同一个模式在上游三个包里各自出现了一次:
| 位置 | 追加什么 | 课程是否实现 |
|---|---|---|
declareToolChangespackages/agent/src/agent-loop.ts:323-363 | 每次请求前比较 transcript 已声明的工具与当前可执行的工具,把差异写成 system 消息上的 toolsAdded / toolsRemoved。 | 没有实现。课程仍然通过 context.tools 每次附带完整工具列表。 |
diffSystemPromptSectionspackages/coding-agent/src/core/system-prompt.ts:198-213 | 比较重放出的段落与期望段落,把变化作为新 system 消息插在本轮用户消息之前(packages/coding-agent/src/core/agent-session.ts:1689-1703)。 | 已实现。第 13 章的 Runtime 对 pi-resources 段落做同样的差异补丁。 |
planSystemEntriespackages/durable/src/harness/prompt.ts:66-93 | 把扩展段落和工具的差异追加成 pi.system entry,工具变化跟在最后一条补丁上。 | 没有实现,课程不涉及 pi-durable。 |
const patch: Record<string, string | null> = {};
for (const [name, text] of Object.entries(current)) {
if (previous[name] !== text) patch[name] = text;
}
for (const name of Object.keys(previous)) {
if (current[name] === undefined) patch[name] = null;
}课程实现的是其中“段落补丁”这一支:第 03 章定义 SystemMessage 并按顺序重放出当前 prompt,第 05 章在出线时把重放结果折叠成一条开头的 system 消息,第 09 章允许prompt(value, { system }) 在用户消息之前追加补丁,第 13 章在每次prompt() 前只为变化的段落生成补丁。课程没有实现的是工具一侧:上游用toolsAdded / toolsRemoved 记录模型可调用集合的变化,下一节的 codemode、tool_search 和 MCP 都依赖这种记录在同一个 run 中改变工具集合,并在恢复会话时从 transcript 读回。
codemode 与工具暴露级别
codemode 是一个参数为一段 JavaScript 的工具。模型调用它时,Pi 为这一次执行新开一个 worker 线程(packages/codemode/src/runtime/host.ts:155),在里面启动一个全新的 QuickJS(WASM)虚拟机(packages/codemode/src/runtime/worker.ts:54-61)。 脚本里的 tools.read() 之类调用通过消息桥回到宿主,跨线程传递的只有 JSON 字符串, 宿主一侧走与普通工具调用相同的参数校验和钩子。
一个工具能被谁看到,由注册时的 exposure 决定。下表的四列都对应packages/coding-agent/src/core/agent-session.ts:1449-1572里的判断:
| exposure | 声明给模型 | 脚本可调 | tool_search 可搜 | 列在 codemode 描述里 |
|---|---|---|---|---|
direct | 激活时 | 激活时 | 否 | on 模式不列,只在已声明工具的描述后追加一行脚本调用说明;only 模式列出 |
model-only | 激活时(注册即激活) | 从不 | 否 | 否 |
codemode | 仅显式激活时 | 是 | 未激活时可搜 | 在 inlineBudget 内列出 |
deferred | 被 tool_search 激活后 | 是 | 未激活时可搜 | 从不 |
hidden | 否 | 否 | 否 | 否 |
return exposure === "codemode" || exposure === "deferred"
|| (exposure === "direct" && active.has(tool.name));声明给模型的集合是“已激活且不是 hidden”的工具;注册时只有 direct 和model-only、并且 defaultActive !== false 的工具会被激活。codemode 和 tool_search 自己都注册为 model-only(packages/coding-agent/src/extensions/codemode/tool.ts:377、packages/coding-agent/src/extensions/tool-search/tool.ts:232), 所以脚本里既不能再启动一段 codemode,也不能调用 tool_search。tool_search 命中后把匹配的工具加入激活集合,下一次请求前由declareToolChanges 写进 toolsAdded,同一个 run 里就能生效。
一次 codemode 调用的路径
- 模型发出
codemode{code},runToolCall照常校验参数并触发tool_call钩子。 - 执行器把可调用集合映射成沙箱里的
tools命名空间,再调用sandbox.execute()新建 worker(packages/coding-agent/src/extensions/codemode/execute.ts:320-394)。 - 脚本里
await tools.read(…)经消息桥发出call,宿主执行对应工具。 这是一次嵌套调用:id 形如<父 id>/1,同样经过校验,同样触发tool_call/tool_result,事件上带parentToolCallId(packages/coding-agent/src/core/nested-tool-calls.ts:175-248)。 - 有
outputSchema的工具把structuredContent交给脚本,否则交文本;出错时脚本里的 Promise 被 reject。 - 脚本结束后,宿主先置共享中断标志,再终止 worker(
packages/codemode/src/runtime/host.ts:242-272)。输出超过上限时截断,完整内容落到临时文件。 - codemode 的结果消息上挂一份
nestedCalls记录,并合并各层嵌套调用的 usage。
export const NESTED_CALL_LIMITS = {
maxCalls: 256,
maxArgumentBytesPerCall: 8 * 1024,
maxArgumentBytesTotal: 32 * 1024,
maxErrorChars: 500,
} as const;这组上限只约束记录,不约束执行。第 257 次调用照样执行,只是不再记录;maxArgumentBytesTotal 是所有调用参数加起来的字节数,超长的参数换成字节数并标complete: false;工具结果本身从不记录(packages/coding-agent/src/core/nested-tool-calls.ts:64-69)。
MCP 配置里也可以写 codemode 暴露,但注册时它被映射成核心的 deferred:
export function toToolExposure(exposure: McpExposure): ToolExposure {
return exposure === "codemode" ? "deferred" : exposure;
}结果是 MCP 工具不会列在 codemode 描述里。脚本知道名字就能直接调用它们;名字本身要由模型通过tool_search 或脚本里的 searchTools() 查到,两者使用同一个排序器。 模型知道有哪些 MCP server,靠的是下一节的 mcp_servers 段落。
MCP
MCP 支持分成两层。packages/mcp(pi-mcp)是一个不依赖其他 Pi 包的 MCP 客户端,负责协议、 stdio 与 Streamable HTTP 两种传输以及 OAuth;coding-agent 的内置 MCP 扩展负责读配置、管理连接、 注册工具和维护 system prompt 里的 mcp_servers 段落。扩展的设计目标有两条:MCP 不拖慢第一个 prompt,也不破坏 prompt cache。
会话时间线
session_start:读全局mcp.json,项目已被信任时再读.pi/mcp.json;按配置激活 codemode 或tool_search;然后用setImmediate在后台连接所有启用的 server(packages/coding-agent/src/extensions/mcp/index.ts:954-991)。before_agent_start:按当前连接状态重新渲染mcp_servers段落。段落有变化时, core 用上一节的diffSystemPromptSections把补丁作为新 system 消息插在用户消息之前,已有前缀不动(packages/coding-agent/src/extensions/mcp/index.ts:1016-1024)。- 第一个 prompt:只等待带
direct工具的 server,最多 10 秒,每个会话只等一次。超时后照常发请求, 并提示这些工具连上后才会出现。 - codemode 调用:对脚本源码做匹配,只等待脚本点名的 server;脚本用到
searchTools这类发现函数时等待全部 server。这一步没有超时,只受取消信号约束。 - server 发来
tools/list_changed时刷新工具。工具注册后不能注销,被撤下或禁用的工具以hidden重新注册。
if (waitedForStartup) return;
waitedForStartup = true;
const ready = servers.flatMap((server) =>
isEnabled(server) && hasDirectTools(server.entry) && server.ready
? [server.ready] : []);
if (ready.length === 0) return;
const finished = await Promise.race([
Promise.all(ready).then(() => true),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), startupWaitMs);
}),
]);工具命名
const name = `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_]/g, "_");
if (name.length <= MAX_TOOL_NAME_LENGTH && !isTaken(name)) return name;
const hash = createHash("sha256")
.update(`${server}\0${tool}`).digest("hex").slice(0, 8);工具名形如 mcp__<server>__<tool>,非字母数字下划线的字符替换成下划线。 名字超过 64 个字符,或替换后与同一 server 内的其他工具重名,就截短并附上server\0tool 的 SHA-256 前 8 位。重名判定与工具列表的顺序无关: 替换后撞名的所有工具都会加 hash,所以 server 调换列表顺序不会让工具名互换。mcp_servers 段落整体上限 4096 字符、每条描述 250 字符,放不下的 server 在末尾折叠成一行计数。
OAuth 要点
- 先按 RFC 9728 查受保护资源元数据,带路径的 well-known 地址失败时回退到根路径(
packages/mcp/src/oauth/discovery.ts)。 - 授权服务器元数据依次尝试
oauth-authorization-server与openid-configuration,文档里的issuer必须与请求地址一致。 - 客户端身份优先用 client ID metadata document,不支持时动态注册;两者都没有则报错。
- PKCE 只接受
S256,授权请求带 RFC 8707 的resource参数。 - 1.0.0 新增:回调时按 RFC 9207 校验
iss,与元数据里的 issuer 不一致就不交换授权码。 这一条在docs/mcp.md和 README 里都还没有写。 - token 响应没有给 scope 时,记为请求时的 scope;
invalid_client清空全部凭证重来,invalid_grant只清 token。coding-agent 把凭证存在~/.pi/agent/mcp-auth.json,跨进程刷新用锁文件协调(packages/coding-agent/src/extensions/mcp/oauth.ts)。
// RFC 9207: never send a code from another authorization server to this one.
const iss = options.iss;
if (metadata && (iss !== undefined
|| metadata.authorization_response_iss_parameter_supported)) {
if (iss !== metadata.issuer) throw new OAuthIssuerMismatchError(metadata.issuer, iss);
}虚拟模型
虚拟模型是一个 api: "pi-virtual" 的普通 Model,背后是一个route() 函数(packages/coding-agent/src/core/virtual-models.ts:30-104)。 它把“选择”和“派发”分开:agent.state.model 始终是用户选中的虚拟模型;每次请求前,prepareRequest 调用 route(),得到这一次请求实际使用的物理模型和思考级别, 结果只对这一次请求生效(packages/coding-agent/src/core/agent-session.ts:759-815)。
同一个 1.0 里,chat、image、classifier 三类模型也合进了一套 Models 接口,用type 字段区分,不写 type 就是 chat(packages/ai/src/types.ts:1097-1168)。虚拟模型只作用在 chat 请求上,路由器可以在route() 里调用 classifier 模型来做选择。
const lastResponse = context.messages.findLastIndex((m) => m.role === "assistant");
const userTurn = context.messages.slice(lastResponse + 1).some((m) => m.role === "user");
…
reason: failed ? "retry" : userTurn ? "user" : "continuation",route() 收到的 request.reason 有四种取值(packages/coding-agent/src/core/virtual-models.ts:50):
| reason | 何时出现 | 路由器能利用什么 |
|---|---|---|
user | 最后一条 assistant 之后出现了用户消息,即新一轮开始 | 可以自由选择物理模型和思考级别;思考级别会被限制在目标模型支持的范围内。在这里调用分类器,延迟会加在首个 token 之前。 |
continuation | 工具结果之后的续写 | request.previous 是最近一次有效回复用的物理模型(跳过 error / aborted),沿用它能保住 prompt cache。 |
retry | 自动重试或上下文溢出压缩之后 | request.failed 是失败的那次回复;如果失败的是路由本身,它为 undefined。 |
direct | Agent 循环之外的请求,例如扩展调用、压缩和摘要 | 不传 state,返回的 state 也被忽略(packages/coding-agent/src/core/model-runtime.ts:717-730)。 |
路由器可以返回一份自己的状态,例如“这一段对话已经升级到大模型”。这份状态写成pi.virtual-model-state 自定义 entry,不进入模型上下文;下一次路由时沿当前分支倒序找最近一条(packages/coding-agent/src/core/virtual-models.ts:158-167)。 新旧状态按引用比较,返回一个内容相同的新对象也会再存一条 entry。因为 compaction 只追加不删除,这份状态在压缩后仍能找到。
会话里同时留有两类记录:选择记在 model_change / thinking_level_change entry 上, 派发结果记在每条 assistant 消息的 provider / api / model 字段上。route() 抛错、目标未注册或没有凭证时,Agent.handleRunFailure合成一条 error 消息,这条消息归属于虚拟模型(packages/agent/src/agent.ts:525-548)。
pi-durable
pi-durable(packages/durable)是 1.0 首发的独立库,由早先 agent-core 里的实验性 harness 拆出来重写,README 第一行就标着 Experimental(packages/durable/README.md:3)。主力 CLI coding-agent 目前没有使用它:package.json 里没有这个依赖,src/ 下引用 pi-durable 的文件全部在experimental/ 目录,主线会话仍由 core/session-manager.ts 负责。 课程第 10 章的会话树对应的是后者。
pi-durable 的核心规则是先提交,再可见。同一个 Session 的所有变更排在一条 promise 链上依次执行; 每次提交是一次 Storage.commit(writes) 批量写入,只有提交成功后,其他部分才能看到这次变更。 写入失败而又不能确认“什么都没写进去”时,Session 把自己标记为 poison,之后的操作都会失败。
#enqueue<T>(job: () => Promise<T>): Promise<T> {
const run = this.#tail.then(job);
this.#tail = run.then(() => undefined, () => undefined);
return run;
}seq = await this.#storage.commit(writes, withoutAbortSignal(context));
} catch (error) {
tx.discard();
if (!(error instanceof StorageRejected)) this.#poison = { error };
throw error;
}崩溃后怎样恢复
重新打开时,调度器先在一次提交里把所有 running 任务改回 pending,但不立即调度; 调用 resume()、提交新输入或等待结果时才启动(packages/durable/src/harness/scheduler.ts:230-262)。 一轮对话里不同时刻断电,恢复结果不同:
| 崩溃时刻 | 重新打开后 |
|---|---|
| 用户输入已提交 | pi.user entry、submission 和 generation 任务在同一次提交里落盘,任务照常执行。客户端用同一个 requestId 重试,会拿回同一个 submission。 |
| 模型正在流式输出 | 用同样的上下文从头重发模型请求。已经提交的部分输出变成一条 stopReason: "aborted"的 assistant entry,会话视图里看得到,但不会再送给模型(packages/durable/src/harness/generation.ts:184-195、packages/durable/src/harness/context.ts:8)。 |
| 回复已提交,工具还没开始 | 工具意图尚未提交,任务从调用阶段重来,beforeTool 钩子会再跑一次。 |
| 工具 execute() 运行中 | 只有存储的意图和当前注册的工具都声明 replay: "safe" 时才清掉旧进度并重跑; 否则模型收到一条“工具被中断,可能已部分执行”的错误结果,任务以 failed 结束。 |
| 工具结果已提交 | 不会重跑这个工具,generation 任务等其余工具结束后发起下一次模型请求。 |
const tool = (await runtime.agent(context)).tools.find((each) => each.name === call.name);
if (replay === "safe" && tool?.replay === "safe") {
// 清掉被中断那次发布的进度,从头重跑
}工具的意图在 execute() 之前提交,提交时记下当时注册的 replay 值,缺省为"unsafe"(packages/durable/src/harness/tool.ts:85-91)。 恢复时还要再看一次当前注册的工具,两边都是 "safe" 才重跑;这样一个工具在两个版本之间改了 replay 声明,也不会被误重跑。
README 说部分输出至多每 100 ms 提交一次。代码里是一个 100 ms 的尾随定时器,加上最多一次在途提交,所以“最多丢 100 ms”只是近似。操作系统级崩溃可能丢得更多:SQLite 后端使用 synchronous = NORMAL(packages/durable/src/storage/sqlite/node.ts:190-191),JSONL 后端默认不 fsync(packages/durable/src/storage/jsonl/storage.ts:256)。
推荐阅读顺序
每组按列出的顺序读。先读 system 消息模型那一组,它连接课程与后面四组;其余各组彼此独立。
system 消息模型
packages/ai/src/types.ts:512-538SystemMessage 的字段与增量语义packages/ai/src/utils/transcript.ts:73-123重放出当前 prompt,以及给不支持中途 system 的 provider 折叠packages/agent/src/agent-loop.ts:323-363declareToolChanges:工具集合的差异packages/coding-agent/src/core/system-prompt.ts:198-213diffSystemPromptSections:段落差异packages/coding-agent/src/core/agent-session.ts:1689-1703补丁在每次 prompt 前生成
codemode 与工具暴露
packages/coding-agent/src/core/extensions/types.ts:367-615ToolExposure、ToolLoadout、prepareLoadout 的契约packages/coding-agent/src/core/agent-session.ts:1449-1572可调用集合、声明集合与 loadoutpackages/coding-agent/src/extensions/codemode/tool.ts工具定义、描述构建与 inlineBudgetpackages/coding-agent/src/extensions/codemode/execute.ts单次执行、store 与输出截断packages/codemode/src/runtime/host.tsworker 生命周期与消息桥的宿主一侧packages/coding-agent/src/core/nested-tool-calls.ts嵌套调用的 id、记录上限与 usage 汇总
MCP
packages/mcp/src/client.ts初始化、请求、超时、进度、取消与分页packages/mcp/src/oauth/flow.tsPKCE、iss 校验、token 保存与 step-uppackages/coding-agent/src/core/mcp-servers.ts配置类型、校验与暴露级别packages/coding-agent/src/extensions/mcp/index.ts会话事件、启动等待与 mcp_servers 段落packages/coding-agent/src/extensions/mcp/tools.ts工具命名与注册
虚拟模型
packages/coding-agent/src/core/virtual-models.ts类型、路由请求、选择与状态的读取packages/coding-agent/src/core/model-runtime.ts:994-1032resolveModel:把虚拟选择解析成物理模型packages/coding-agent/src/core/agent-session.ts:759-815prepareRequest 中的路由与状态记录packages/agent/src/agent.ts:525-548路由失败怎样变成一条 error 消息
pi-durable
packages/durable/README.md概念与 Experimental 声明packages/durable/src/types.ts记录、Storage 与事务接口packages/durable/src/session/session.ts变更线、提交与 poisonpackages/durable/src/harness/scheduler.ts任务认领、恢复与归属packages/durable/src/harness/generation.ts模型请求的检查点与崩溃后重发packages/durable/src/harness/tool.ts工具意图与 replay 判断
本页内容依据上游 v1.0.0 源码整理,代码片段只做必要节选。
回到第 13 章 →