Chương 14Phần 4: Pi 1.0
Pi 1.0 đã thay đổi những gì
Exposure, namespace và loadout, tool hoãn nạp, làm ấm cache, virtual model và ModelRuntime.
Trong chương này19 mục
- Vai trò: “Pi 1.0” nghĩa là gì
- Dòng thời gian phát hành
- Mô hình tư duy: prefix ổn định và mô hình ngoài chat
- Data model: exposure, namespace và loadout
- AgentSession áp dụng loadout
- Thông điệp hệ thống giữa hội thoại
- pi-ai đưa thay đổi tool lên dây
- Trace: nạp một tool hoãn lại giữa hội thoại
- Làm ấm cache cho Anthropic
- Virtual model
- ModelRuntime: mô hình ảnh và classifier
- Toàn màn hình, theme hệ thống và đăng nhập
- Bộ cài managed, install lock và Nix flake
- Pi Durable phát hành cùng lúc
- Sau 1.0: các thay đổi kiến trúc của 1.1 tới 1.4
- Quyết định thiết kế
- Cạm bẫy và trường hợp biên
- Đọc code ở đâu
- Tóm tắt
Vai trò: “Pi 1.0” nghĩa là gì#
Ngày 2026-10-01 Earendil phát hành Pi 1.0 và gọi nó là “a hardened, minimal, extensible agent harness that you can make your own”, kèm mục tiêu biến Pi thành “a stable piece of software that people and businesses can depend on” (Pi 1.0). Bối cảnh lịch sử và triết lý dẫn tới mốc này nằm ở chương Lịch sử và triết lý; chương này chỉ hỏi một câu kỹ thuật: trong code, những gì đã đổi và chúng hoạt động thế nào.
Bài công bố liệt kê bảy điểm mới: codemode (hỗ trợ MCP gốc, mô hình không phải LLM như Jev, mô hình sinh ảnh), virtual model cho extension, nạp tool hoãn lại (deferred tool loading), làm ấm cache cho mô hình Anthropic, system message giữa hội thoại, theme TUI mới, và chế độ toàn màn hình mặc định. Đọc changelog thì thấy phần lớn các điểm đó không nằm trong bản 1.0.0. Changelog 1.0.0 của coding agent khá ngắn: toàn màn hình mặc định, codemode gọn hơn khoảng 40% token, sinh ảnh trong codemode, đăng nhập Radius và Anthropic kiểu copy code, gia cố OAuth cho MCP (CHANGELOG.md:324–334). Các thay đổi lớn đến từ hai bản trước đó:
| Điểm trong bài công bố | Vào từ bản | Cơ chế chính | Đọc ở |
|---|---|---|---|
| System message giữa hội thoại | 0.86.0 (CHANGELOG.md:609) | SystemMessage trong transcript, declareToolChanges(), diff các mục prompt | Thông điệp hệ thống, lên dây |
| Làm ấm cache cho Anthropic | 0.86.0 (:619) | CacheWarmer, Model.promptCache | Làm ấm cache |
| Codemode và MCP | 0.99.0 (:440) | Gói pi-codemode, pi-mcp, ba built-in extension | chương Codemode, MCP |
| Nạp tool hoãn lại | 0.99.0 (:441) | exposure, prepareLoadout(), tool_search | Data model, loadout |
| Virtual model | 0.99.0, thử nghiệm (:443) | registerVirtualModel(), router mỗi request | Virtual model |
| Mô hình Jev và mô hình ảnh | 0.99.0 (:451–452), codemode sinh ảnh ở 1.0.0 | ModelType, classify(), generateImages() | ModelRuntime |
| Theme mới, toàn màn hình | 0.99.0 và 1.0.0 (:445, :328) | Theme system, tuiMode: "fullscreen" | Toàn màn hình, theme |
Vì vậy “1.0” nên hiểu là một mốc ổn định đặt sau một loạt bản dồn dập trong hai tuần, chứ không phải một bản chứa mọi thứ mới. Mốc này cũng là lúc Pi tách bạch hai sản phẩm: pi-agent-core bỏ hẳn harness thử nghiệm cũ, chỉ giữ Agent, vòng lặp, proxy stream và các kiểu của chúng (agent/CHANGELOG.md:35), còn harness bền ra mắt cùng ngày dưới dạng gói riêng, gắn nhãn thử nghiệm (mục Pi Durable).
Chương này đi qua cơ chế của từng điểm, theo thứ tự mà một request đi qua chúng: mô hình dữ liệu của tool, cách AgentSession chọn những gì được khai báo, cách thay đổi được ghi vào transcript, và cách pi-ai đưa nó lên dây cho từng provider. Codemode, MCP và tool_search là những người dùng các cơ chế này; phần ruột của chúng thuộc chương Codemode, MCP và tool search. Vòng lặp và AgentSession được giả định là đã quen (chương pi-agent-core, chương AgentSession).
Dòng thời gian phát hành#
Ngày lấy theo tiêu đề changelog (## [x.y.z] - date). Commit phát hành thường được tạo ngay sau nửa đêm giờ châu Âu (+02:00), nên ngày của tag trong git có thể muộn hơn một ngày, ví dụ tag v1.4.0 mang ngày 2026-10-22 00:29 +0200. Số phiên bản nhảy thẳng từ 0.87.1 lên 0.99.0 (CHANGELOG.md:428). Bảng chỉ giữ thay đổi kiến trúc, không liệt kê sửa lỗi hay mô hình mới.
| Bản | Ngày | Thay đổi kiến trúc |
|---|---|---|
| 0.86.0 | 2026-09-19 | System prompt và tool sống trong transcript: provider nhận TranscriptContext thay cho Context (thay đổi phá vỡ cho provider tự viết, ai/CHANGELOG.md:279). Làm ấm cache, Model.promptCache, /bug. |
| 0.87.0 | 2026-09-21 | SessionManager là nguồn chuẩn của context gửi provider, ContextEditEntry, boundary turn_end và agent_before_settle, sự kiện context_with_system; finishTurn thay shouldStopAfterTurn. |
| 0.99.0 | 2026-09-29 | Codemode, tool_search, MCP thành built-in extension; hai gói mới pi-codemode, pi-mcp (commit 8562bcf66). API exposure, namespace, prepareLoadout(), ctx.executeTool(). Virtual model, classifier và mô hình ảnh trong ModelRuntime, theme system, Sign in with ChatGPT. Build chuyển sang TypeScript 7 và chế độ type stripping của Node. |
| 0.99.1–0.99.2 | 09-29, 09-30 | Server MCP rời khỏi mô tả codemode, chuyển sang mục mcp_servers của system prompt; prompt đầu không còn chờ server; tên tool MCP đổi - thành _. |
| 1.0.0 | 2026-10-01 | Toàn màn hình mặc định; codemode gọn hơn và có models.generateImages(); OAuth MCP kiểm tra iss (RFC 9207), credential theo từng server; pi-agent-core gỡ harness cũ; Pi Durable 1.0.0 ra mắt. |
| 1.0.1 | 2026-10-03 | Nix flake; override server MCP theo dự án; CIMD; registerToolRenderer(); Anthropic định nghĩa tool mới ngay trong hội thoại (beta inline-tools-2026-09-15, ai/CHANGELOG.md:150); bỏ npm-shrinkwrap.json, ghim bắc cầu chuyển sang bộ cài pi.dev. |
| 1.0.2–1.0.4 | 10-04, 10-05 | samplingParamsByThinkingLevel; đổi tên provider azure; mẫu * trong --tools, --no-mcp; built-in của codemode bị đóng băng; ToolLoadout.getPromptGuidelines(). |
| 1.1.0 | 2026-10-07 | Báo trạng thái chương trình qua OSC 7501; durationMs trên kết quả tool và assistant message; hàm stream bắt buộc trả về AssistantMessageEventStream; classifier GPT-6 Luna qua Decisions API, classifier llama.cpp gốc. |
| 1.2.0 | 2026-10-12 | reachesTools, alias cho namespace; schema cấu hình công bố trên pi.dev; before_agent_start cho cả lần chạy do extension kích hoạt; pi-codemode có RemoteCodemodeSandbox, InlineCodemodeSandbox và lần chạy tất định start(); gỡ bộ server/client/plugin thử nghiệm và phụ thuộc Chord. |
| 1.3.0 | 2026-10-15 | Đổi mức suy nghĩ trên GPT-6 mà giữ cache; Kimi Code qua Chat Completions, tool thêm giữa hội thoại không phá cache; bỏ khối thinking của mô hình khác khi đổi mô hình; session khôi phục giữ bộ tool đang bật. |
| 1.4.0 | 2026-10-21 | Client identity (User-Agent, attribution) trong pi-ai; span telemetry quanh request; TranscriptToolSupport thay cho cờ boolean, OpenAI Responses khai báo mọi tool trong additional_tools; classifier nhận ảnh; codemode xếp hàng chờ worker; tóm tắt không còn trần output cố định. |
Mô hình tư duy: prefix ổn định và mô hình ngoài chat#
Hai mạch ý chạy xuyên qua các bản 0.86 tới 1.0. Mạch thứ nhất là nối thêm thay vì viết lại. Prompt cache của provider là cache của một tiền tố (prefix) token chính xác (chương pi-ai); mọi thay đổi ở phần đầu request, dù chỉ là thêm một tool, làm trượt cache cho toàn bộ hội thoại phía sau. Bài “Prompt Caching In Agents” tóm lại: thêm vài token schema có thể buộc provider xử lý lại hàng chục nghìn token hội thoại (Prompt Caching In Agents). Vì thế mục prompt và bộ tool, khi đổi giữa hội thoại, được biểu diễn thành một thứ nối vào cuối transcript, và adapter của pi-ai cố gửi nó tại chỗ. Mức suy nghĩ đi cùng hướng: với mô hình hỗ trợ, adapter dựa vào mức đã ghi trên từng câu trả lời cũ để gửi phần thay đổi mà không sửa phần đầu (mục 1.3.0). Làm ấm cache là cùng ý ấy theo chiều thời gian: giữ cho prefix không hết hạn khi tool chạy lâu.
Mạch thứ hai là mô hình không chỉ để chat. Catalog có ba loại mô hình (chat, ảnh, classifier), mỗi loại một thao tác riêng, và có thêm virtual model: một mục chọn được trong /model nhưng không bao giờ tới provider, vì trước mỗi request một router chọn mô hình thật thay cho nó.
extension, MCP đăng ký tool các mục prompt: tools, rules,
(exposure, namespace, prepareLoadout) skills, mcp_servers, ...
│ │
▼ ▼
AgentSession._applyToolLoadout() buildSystemPromptSections()
bộ đang bật → tool được khai báo diffSystemPromptSections()
prepareLoadout → mô tả, hiddenDeclarations → SystemMessage { sections }
│ agent.state.tools │
└─────────────────────┬─────────────────────┘
▼
agent-loop: declareToolChanges() nối vào transcript (và session):
SystemMessage { toolsAdded, toolsRemoved, sections }
│
▼ transformContext: lọc hiddenDeclarations
pi-ai: resolveTranscript() / resolveTranscriptTools()
├─ Anthropic (native) tool_addition / tool_removal ngay trong hội thoại
├─ OpenAI Responses additional_tools, hoặc tool_search_call/output
├─ OpenAI Completions (Kimi) system message mang trường tools
└─ các API khác gộp mọi system message vào message đầu
(prefix đổi, cache trượt)
Từ vựng dùng trong phần còn lại:
- Bộ tool đang bật (active set)
- Các tool được khai báo cho mô hình, tức
agent.state.tools;getActiveToolNames()trả về tên của chúng (agent-session.ts:1498). - Tool gọi được (callable)
- Các tool mà một tool khác chạy được qua
ctx.executeTool(), như script codemode làm. Gọi được không đồng nghĩa với được khai báo. - Exposure
- Thuộc tính của từng tool, quyết định nó được khai báo, gọi được, hay cả hai.
- Loadout
- Thứ mô hình nhìn thấy về tool trong một request: danh sách khai báo cùng mô tả của chúng, sau khi các hook
prepareLoadoutđã sửa. - Hoãn lại (deferred)
- Tool đã đăng ký và gọi được nhưng chưa khai báo; nó chỉ được khai báo khi có thứ gì đó (thường là
tool_search) bật nó lên. - System message giữa hội thoại
- Một
SystemMessagenằm sau message đầu tiên, mang phần chênh lệch của prompt hoặc bộ tool kể từ điểm đó.
Data model: exposure, namespace và loadout#
Vấn đề. Trước 0.99, một tool của extension chỉ có hai trạng thái: đang bật thì được khai báo và gọi được, tắt thì không tồn tại với mô hình. Server MCP lớn có hàng chục tool; khai báo hết thì tốn token và phá cache mỗi khi server kết nối, mà tắt thì script codemode cũng không gọi được. Cần tách “mô hình thấy gì” khỏi “runtime chạy được gì”. Đó là việc của ToolExposure:
/** * How the model reaches a tool. "Callable" means callable from other tools through * `ctx.executeTool()`, as the `codemode` tool does. * * - `direct`: declared to the model while active, and callable while active. * - `model-only`: declared to the model while active, never callable. Use it for orchestrating or * interactive tools. * - `codemode`: callable whenever registered. Not declared to the model unless explicitly * activated. Codemode tools list it in their description. * - `deferred`: like `codemode`, but codemode tools do not list it; tool search can find it. * - `hidden`: registered but unreachable. Activating it has no effect. * * `direct` and `model-only` tools are activated when they are registered; the others are not. * The active tool set (`getActiveTools`/`setActiveTools`) is the set declared to the model. */export type ToolExposure = "direct" | "model-only" | "codemode" | "deferred" | "hidden";getActiveTools/setActiveTools) chính là bộ được khai báo cho mô hình.| exposure | Khai báo cho mô hình | Gọi được qua ctx.executeTool | Bật khi đăng ký | Mô tả codemode liệt kê | tool_search tìm được |
|---|---|---|---|---|---|
direct (mặc định) | khi đang bật | khi đang bật | có | chỉ với codemode.mode: "only" | không |
model-only | khi đang bật | không bao giờ | có | không | không |
codemode | chỉ khi bật tường minh | luôn, khi đã đăng ký | không | có | có |
deferred | chỉ khi bật tường minh | luôn, khi đã đăng ký | không | không | có |
hidden | không | không | không (bật cũng vô hiệu) | không | không |
Cột “gọi được” đến từ _getCallableTools() (agent-session.ts:1578–1587); cột codemode từ prepareCodemodeLoadout() (codemode/tool.ts:331); cột tool search từ isSearchable() (tool-search/tool.ts:186). Hai tool điều phối, codemode và tool_search, tự đăng ký với model-only: mô hình gọi được chúng, script thì không, nên một script không thể khởi chạy script khác (codemode/tool.ts:382). Server MCP có bộ exposure riêng ánh xạ sang bộ này; đáng chú ý là exposure mặc định codemode của MCP thành deferred của pi, nên tool MCP không làm phình mô tả codemode (mcp/tools.ts:40–46, xem chương Codemode, MCP).
Tool điều phối các tool khác cần nhìn toàn cảnh để tự mô tả mình. Pi đưa cho nó một ảnh chụp và nhận lại các thay đổi:
/** The tools of a session as {@link ToolDefinition.prepareLoadout} sees them. */export interface ToolLoadout { /** Tools declared to the model (the active tools), in order, with their original descriptions. */ readonly declared: readonly AgentTool[]; /** Tools callable through `ctx.executeTool()`. */ readonly callable: readonly AgentTool[]; /** Every registered tool. */ readonly registered: readonly AgentTool[]; getExposure(name: string): ToolExposure; getNamespace(name: string): ToolNamespace | undefined; /** A tool's `promptGuidelines`. Hidden declarations leave them out of the system prompt. */ getPromptGuidelines(name: string): readonly string[];}/** Changes {@link ToolDefinition.prepareLoadout} makes to what the model sees. */export interface ToolLoadoutChanges { /** Model-facing descriptions of declared tools, by tool name. */ descriptions?: Readonly<Record<string, string>>; /** * Declared tools whose declarations requests leave out. They stay active and callable, and the * transcript still declares them, so the active set survives `/tree` and resume. */ hiddenDeclarations?: readonly string[];}hiddenDeclarations là cách chế độ codemode.mode: "only" giấu read, bash... khỏi request mà không tắt chúng: chúng vẫn đang bật, vẫn gọi được, và transcript vẫn khai báo chúng.Các trường mới của ToolDefinition nằm cạnh nhau:
/** * JSON Schema of `structuredContent` in successful results. Tools that declare it should always * set `structuredContent`; codemode scripts then receive it instead of the text content. */outputSchema?: TSchema;/** * How the model reaches the tool. Default: `"direct"`. See {@link ToolExposure}. */exposure?: ToolExposure;/** Group the tool belongs to, for example its MCP server. */namespace?: ToolNamespace;/** * For tools that reach other tools (such as `codemode`, which calls them, and `tool_search`, which * loads them): which tools a call with `input` may reach. `"all"`, or the words of the input that * may name them, where a word refers to a tool or namespace whose name it contains, so words may * over-match. Extensions that register tools late, such as MCP servers that are still connecting, * use it from their `tool_call` handler to finish registering those tools before the call runs. */reachesTools?: (input: Record<string, unknown>) => "all" | readonly string[];/** Hints about what the tool does, for example from an MCP server. */annotations?: ToolAnnotations;/** * Whether registering the tool activates it. Default: `true` for `direct` and `model-only` tools; * other exposures are never activated on registration. A tool with `defaultActive: false` is * activated by naming it in `--tools` or the `defaultTools` setting, or with `setActiveTools()`. */defaultActive?: boolean;/** * Adjust how the loadout is presented to the model while this tool is active. Called whenever * the active tools change. Tools that orchestrate other tools use it, for example to list the * callable tools in their own description. */prepareLoadout?: (loadout: ToolLoadout) => ToolLoadoutChanges | undefined;outputSchema cho script nhận structuredContent thay vì text; namespace gom tool của một server MCP; reachesTools (1.2.0) cho extension biết một lời gọi có thể chạm tới tool nào, để chờ server MCP còn đang kết nối.ToolNamespace (types.ts:533–549) có name như mcp__docs, aliases, một description ngắn hiện trong danh sách tool, và instructions dài chỉ trả về khi script gọi describeNamespace(). Sự tách hai trường này có chủ đích: phần hiện thường trực phải ngắn và ổn định, phần dài chỉ tốn token khi thật sự cần.
AgentSession áp dụng loadout#
Mọi đường đổi bộ tool, gồm setActiveTools() của extension, tool_search, /reload, khôi phục session, đều dồn về một hàm. Nó lọc tên được yêu cầu, gọi các hook prepareLoadout của những tool đang bật và gán kết quả vào agent.state.tools, chính là thứ vòng lặp sẽ so với transcript:
private _applyToolLoadout(toolNames: string[]): AgentTool[] { const tools = [...new Set(toolNames)].flatMap((name) => { const tool = this._toolRegistry.get(name); return tool && this._getToolExposure(name) !== "hidden" && this._isActivatable(name) ? [tool] : []; }); const hooks = tools.flatMap((tool) => { const entry = this._toolDefinitions.get(tool.name); return entry?.definition.prepareLoadout ? [entry] : []; }); const hidden = new Set<string>(); let declared = tools; if (hooks.length > 0) { const loadout: ToolLoadout = { declared: tools, callable: this._getCallableTools(new Set(tools.map((tool) => tool.name))), registered: [...this._toolRegistry.values()], getExposure: (name) => this._getToolExposure(name), getNamespace: (name) => this._toolDefinitions.get(name)?.definition.namespace, getPromptGuidelines: (name) => this._toolPromptGuidelines.get(name) ?? [], }; const descriptions = new Map<string, string>(); for (const { definition, sourceInfo } of hooks) { try { const changes = definition.prepareLoadout?.(loadout); for (const [name, description] of Object.entries(changes?.descriptions ?? {})) { descriptions.set(name, description); } for (const name of changes?.hiddenDeclarations ?? []) hidden.add(name); } catch (error) { this._extensionRunner.emitError({ extensionPath: sourceInfo.path, event: "prepare_loadout", error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); } } declared = tools.map((tool) => { const description = descriptions.get(tool.name); return description === undefined ? tool : { ...tool, description }; }); } this._hiddenDeclarations = hidden; this.agent.state.tools = declared; return declared;}hidden và tool MCP mà allowlist --tools giữ lại nhưng không nêu tên bị loại ngay dòng 1598 (_isActivatable, :1563). Hook ném lỗi chỉ bị báo như lỗi extension với sự kiện prepare_loadout; loadout vẫn được áp dụng.Hook chỉ đổi mô tả hướng tới mô hình (dòng 1632–1635), không đổi tool. Codemode dùng nó để chèn vào mô tả của chính nó danh sách các tool script gọi được, và ở chế độ on để thêm vào mô tả của read, bash... một dòng “Codemode: tools.read(args) resolves to ...” (chương Codemode). Các khai báo bị giấu không bị gỡ khỏi transcript mà bị lọc khỏi request, bằng một phép chiếu trên transformContext:
/** * Remove the declarations that `prepareLoadout` hooks hide from every request. The whole * transcript is filtered with the current set, so the projected declarations stay consistent * across requests and only change when the loadout does. */private _installHiddenDeclarationsProjection(): void { const previousTransformContext = this.agent.transformContext; this.agent.transformContext = async (messages, signal) => { const transformed = previousTransformContext ? await previousTransformContext(messages, signal) : messages; const hidden = this._hiddenDeclarations; if (hidden.size === 0) return transformed; return transformed.map((message) => { if (message.role !== "system" || (!message.toolsAdded && !message.toolsRemoved)) return message; const { toolsAdded, toolsRemoved, ...rest } = message; const added = toolsAdded?.filter((tool) => !hidden.has(tool.name)) ?? []; const removed = toolsRemoved?.filter((tool) => !hidden.has(tool.name)) ?? []; return { ...rest, ...(added.length > 0 ? { toolsAdded: added } : {}), ...(removed.length > 0 ? { toolsRemoved: removed } : {}), }; }); };}Lý do giữ khai báo trong transcript nằm ở chiều ngược lại: khi mở lại session hoặc đi qua /tree, bộ tool đang bật được dựng lại từ transcript chứ không từ cấu hình:
private _restoreToolsFromTranscript(modifiers: readonly string[] = []): void { this._pendingToolNames.clear(); // All system messages on the branch, including those before a compaction. Compaction snapshots // are derived from these, so replaying them gives the current loadout. const systemMessages = this.sessionManager .getBranch() .flatMap((entry) => (entry.type === "message" && entry.message.role === "system" ? [entry.message] : [])); // No recorded loadout: keep the current tools. if (systemMessages.length === 0) return; // Tools the branch ever added or removed. Removed tools stay inactive. const mentioned = new Set( systemMessages .flatMap((message) => [...(message.toolsAdded ?? []), ...(message.toolsRemoved ?? [])]) .map((tool) => tool.name), ); // Extension tools the branch never mentioned were registered after it recorded its loadout, so // they are activated like newly registered tools. Built-in tools follow `defaultTools` instead. const newTools = [...this._toolRegistry.keys()].filter( (name) => !mentioned.has(name) && !this._baseToolDefinitions.has(name) && this._isActivatedOnRegistration(name), ); // The recorded loadout plus the new tools, with `--tools +name`/`-name` applied. const names = applyToolModifiers( [...getCurrentTools(systemMessages).map((tool) => tool.name), ...newTools], modifiers, ); // Recorded tools that are not registered yet, such as MCP tools of servers still connecting, are // activated when they register. Excluded tools never register, so they are not kept pending. this._pendingToolNames = new Set(names.filter((name) => this._isAllowedTool(name))); // Ignores names that are not registered. this._setActiveTools(names);}_pendingToolNames và tự bật khi đăng ký (:3649). Khi một lần chạy bắt đầu, những tên vẫn chưa đăng ký bị bỏ (:1895–1897).Changelog 1.0.0 ghi lại đúng lỗi mà cơ chế pending xử lý: tool MCP mà tool_search đã nạp bị rơi khi resume hay /reload, vì session khôi phục bộ tool trước khi server MCP kịp kết nối lại (CHANGELOG.md:364). Bản 1.3.0 mở rộng nguyên tắc: session khôi phục giữ bộ tool đang bật, và --tools +name/-name chỉ cộng trừ trên nó (CHANGELOG.md:82).
Thông điệp hệ thống giữa hội thoại#
Vấn đề. Trước 0.86, system prompt và danh sách tool là hai trường riêng của request. Đổi một chữ trong prompt, như cập nhật mục liệt kê server MCP khi một server vừa kết nối, là viết lại phần đầu request. Đổi bộ tool cũng vậy. Bài viết tháng 7 mô tả cơ chế lúc đó: với thay đổi thuần cộng, ghi tên tool mới lên kết quả tool để provider có hỗ trợ nạp tool tại chỗ, còn provider khác nhận lại cả danh sách (Prompt Caching In Agents). Từ 0.86.0 cả prompt lẫn tool sống trong transcript dưới dạng SystemMessage (ai/types.ts:554, đọc từng trường ở chương pi-ai): message đầu là system prompt, message sau mang content bổ sung, sections thay mục theo tên, toolsAdded/toolsRemoved đổi bộ tool.
Phía coding agent, system prompt mặc định được dựng thành các mục có tên (preamble, tools, rules, project_context, skills, cwd..., xem chương Công cụ và system prompt). Trước mỗi lần chạy và trước mỗi lượt kế tiếp, pi so các mục mong muốn với các mục mà mô hình đang có, phát lại từ transcript, và chỉ tạo system message khi có chênh lệch:
/** * Apply a prompt and tool loadout for the next request. Sets the executable tools and * returns a system message patching the prompt sections the model currently has (replayed * from `messages`), or undefined when the prompt is unchanged. Tool changes are declared by * the agent loop before the request. * * A forced prompt does not affect the transcript: the structured sections are still diffed * and persisted, and the forced text is projected onto the request by * {@link _installAgentForcedPromptProjection}. */private _preparePromptAndToolLoadout( options: NormalizedBuildSystemPromptOptions, messages: AgentMessage[] = this.agent.state.messages,): SystemMessage | undefined { options.selectedTools = this._applyToolLoadout(options.selectedTools).map((tool) => tool.name); // The tool list and rules must match the declarations the request carries. options.hiddenTools = [...this._hiddenDeclarations]; const sections = diffSystemPromptSections( getCurrentSystemMessage(messages)?.sections ?? {}, buildSystemPromptSections(options), ); return sections ? { role: "system", content: "", sections, timestamp: Date.now() } : undefined;}/** * Diff the sections the model currently has (replayed from the transcript, so never null) * against the desired ones. Returns a `SystemMessage.sections` patch, or undefined when * nothing changed. */export function diffSystemPromptSections( previous: Record<string, string | null>, current: SystemPromptSections,): Record<string, string | null> | undefined { 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; } return Object.keys(patch).length > 0 ? patch : undefined;}Bản vá ra đời với content: "" và không có tool. Phần tool do vòng lặp điền: declareToolChanges() (agent-loop.ts:342) tìm system message cuối trong các message chờ và thay trường tool của nó bằng chênh lệch giữa transcript đã commit và context.tools; không có thì nó chèn một system message mới. Việc thay trường dùng một hàm nhỏ:
const NO_CHANGES: ToolStateChanges = { toolsAdded: [], toolsRemoved: [] };/** Copy a system message with its tool fields replaced by `changes`; empty lists omit the field. */function withToolChanges(message: SystemMessage, { toolsAdded, toolsRemoved }: ToolStateChanges): SystemMessage { const { toolsAdded: _added, toolsRemoved: _removed, ...rest } = message; return { ...rest, ...(toolsAdded.length > 0 ? { toolsAdded } : {}), ...(toolsRemoved.length > 0 ? { toolsRemoved } : {}), };}declareToolChanges() nằm ở chương pi-agent-core.Kết quả là một transcript chỉ nối thêm. Ví dụ minh hoạ dưới đây theo dõi một session có server MCP linear chưa khai báo description; khi server kết nối, mô tả một dòng của nó lấy từ server instructions, nên mục mcp_servers đổi ở prompt kế tiếp (mcp/index.ts:1176–1184):
Ví dụ minh hoạ (rút gọn, không phải output thật)
[0] system content "" sections { preamble, tools, rules, docs, cwd,
mcp_servers: "...\n- mcp__linear (codemode)" }
toolsAdded [read, bash, edit, write, codemode]
[1] user "Liệt kê issue OAuth đang mở"
[2] assistant tool_use codemode { code: "..." }
[3] toolResult "Script completed ..."
[4] assistant "Có 3 issue ..."
── prompt thứ hai, server đã kết nối, mục mcp_servers đổi ──
[5] system content "" sections { mcp_servers: "...\n- mcp__linear (codemode): Linear issues" }
[6] user "Issue nào gấp nhất?"
Provider nào nhận system message giữa hội thoại thì [5] được gửi tại chỗ, với các mục được đóng khung theo tên để mô hình liên hệ được với prompt gốc:
/** * Render a later system message for APIs that accept system messages mid-conversation. * Section changes are framed by name so the model can relate them to the leading prompt. * This framing is request-time only and may change between versions. */export function renderSystemMessageUpdate(message: SystemMessage): string { const parts: string[] = []; const text = contentText(message.content); if (text.length > 0) parts.push(text); for (const [name, value] of Object.entries(message.sections ?? {})) { parts.push( value === null ? `Removed system prompt section "${name}".` : `Updated system prompt section "${name}":\n\n${value}`, ); } return parts.join("\n\n");}Provider nào không nhận thì pi-ai dựng lại một system message đầu từ trạng thái đã phát lại và bỏ mọi system message phía sau. Kết quả đúng về nội dung, nhưng prefix đổi và cache trượt:
/** * Rebuild the transcript for APIs without mid-conversation system messages: the replayed * system message leads, and every later system message is dropped. */export function collapseSystemMessages(context: TranscriptContext): TranscriptContext { const head = getCurrentSystemMessage(context.messages); const messages = context.messages.filter((message) => message.role !== "system"); return { messages: head ? [head, ...messages] : messages } as TranscriptContext;}/** Keep later system messages in place when the model accepts them; otherwise collapse them. */export function resolveTranscript( context: TranscriptContext, supportsMidConvoSystemMessages: boolean | undefined,): TranscriptContext { return supportsMidConvoSystemMessages ? context : collapseSystemMessages(context);}Việc chọn giữa hai đường dựa trên cờ compat.supportsMidConvoSystemMessages của từng mô hình; Anthropic, các API OpenAI và Mistral gọi resolveTranscript(), còn Google, Vertex và Bedrock luôn gộp (google-shared.ts:195, bedrock-converse-stream.ts:135). Riêng Anthropic giữ system message lại và chỉ chèn ngay trước assistant message kế tiếp, vì tool_result phải đứng sát tool_use (anthropic-messages.ts:1364).
pi-ai đưa thay đổi tool lên dây#
Transcript nói “từ đây có thêm tool X”. Mỗi API của nhà cung cấp diễn đạt câu đó khác nhau, hoặc không diễn đạt được. Từ 1.4.0 pi-ai đặt tên cho ba mức khả năng (ai/CHANGELOG.md:9):
/** * How a transport can express tool changes after the first request. * * - `"none"`: only the top-level tool list exists; it always holds the current tools. * - `"additions"`: tools can be added at a later message, but not removed or redefined. * - `"changes"`: every tool, including the initial ones, is declared in place, and a later * declaration under the same name replaces the earlier one. Removals are not expressible on * the wire, so the transport announces them as text and the removed tools stay declared. */export type TranscriptToolSupport = "none" | "additions" | "changes";/** * Split tool declarations between the top-level request field and in-place changes. * * With `"changes"`, the top-level list stays empty and every system message, including the * leading one, declares its tools in place: an in-place declaration can replace an earlier * in-place one, but not a top-level one. With `"additions"`, a removal or redefinition cannot be * expressed in place, so the top-level list becomes the tool state after the last such change; * later additions stay anchored, and the cached prefix is invalidated once per removal instead * of on every later addition. With `"none"`, the top-level list is the current tool set. */export function resolveTranscriptTools(messages: TranscriptMessages, support: TranscriptToolSupport): TranscriptTools { const systemMessages = messages.filter(isSystemMessage); if (support === "none") { return { requestTools: getCurrentTools(systemMessages), foldedSystemMessages: systemMessages.length }; } if (support === "changes") return { requestTools: [], foldedSystemMessages: 0 }; const folded = Math.max( getInitialSystemMessage(messages) ? 1 : 0, findLastNonAdditiveToolChange(systemMessages) + 1, ); return { requestTools: getCurrentTools(systemMessages.slice(0, folded)), foldedSystemMessages: folded };}foldedSystemMessages cho adapter biết bao nhiêu system message đầu đã được gộp vào trường tools của request; mọi system message sau đó tự mang thay đổi tool tại chỗ."none": chỉ có danh sách tool ở cấp request, luôn là bộ hiện tại. Mọi thay đổi viết lại phần đầu."additions": thêm được tool ở giữa, nhưng không gỡ hay định nghĩa lại được. Danh sách cấp request trở thành trạng thái sau lần gỡ (hoặc định nghĩa lại) cuối cùng, và các lần thêm sau đó neo tại chỗ. Cache chỉ trượt một lần cho mỗi lần gỡ, thay vì ở mọi lần thêm sau đó."changes": mọi tool, kể cả bộ ban đầu, đều được khai báo tại chỗ; khai báo sau cùng tên thay khai báo trước. Danh sách cấp request để trống.
Anthropic: danh sách cố định và khối tool_addition#
Với mô hình có compat.supportsMidConvoToolChanges (compat-schema.ts:485), adapter Anthropic không dùng ba mức trên mà có cách riêng, diễn đạt được cả việc gỡ tool: danh sách tools của request đóng băng ở bộ ban đầu, cộng một tool giữ chỗ hoãn lại không bao giờ được bật:
/** * Stable deferred tool declared whenever native tool changes are in use. Anthropic adds * hidden prompt scaffolding for mid-conversation tool changes; declaring this placeholder * from the first request keeps that scaffolding in the cached prefix, so the first tool * change does not invalidate the cache (measured: full miss without it). It is never * activated and the model cannot see it. */const DEFERRED_TOOL_PLACEHOLDER: BetaTool = { name: "__pi_deferred_placeholder__", description: "Reserved placeholder. Never available. Never call this.", input_schema: { type: "object", properties: {}, required: [] }, defer_loading: true,}; const initialTools = initialSystemMessage?.toolsAdded ?? []; const nativeToolChanges = compat.supportsMidConvoSystemMessages && compat.supportsMidConvoToolChanges && initialTools.length > 0; const toolCacheControl = compat.supportsCacheControlOnTools ? cacheControl : undefined; if (nativeToolChanges) { // Initial tools stay active with the cache breakpoint on the last one, followed by the // placeholder. The list never changes afterwards: later tools are defined by value in // `tool_addition` blocks and withdrawn by `tool_removal`, so the cached prefix survives // every tool change. params.tools = [...convertToolDefinitions(initialTools, toolCacheControl), DEFERRED_TOOL_PLACEHOLDER]; } else { const tools = getCurrentTools(context.messages); if (tools.length > 0) { params.tools = convertToolDefinitions(tools, toolCacheControl); } }Các tool sau đó đi trong chính system message của hội thoại, theo giá trị, cùng beta header inline-tools-2026-09-15 (anthropic-messages.ts:1156):
if (msg.role === "system") { // Later system messages only reach this point when the model accepts them natively; // otherwise the transcript was collapsed into the leading message before conversion. const text = renderSystemMessageUpdate(msg); const blocks: ContentBlockParam[] = []; if (text.length > 0) blocks.push({ type: "text", text: sanitizeSurrogates(text) }); if (convertToolDefinitions) { const added = msg.toolsAdded ?? []; const redefined = new Set(added.map((tool) => tool.name)); for (const tool of msg.toolsRemoved ?? []) { // A new definition under the same name replaces the old one, so no removal is needed. if (redefined.has(tool.name)) continue; blocks.push({ type: "tool_removal", tool: { type: "tool_reference", name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name }, }); } for (const definition of convertToolDefinitions(added)) { blocks.push({ type: "tool_addition", tool: { type: "tool_definition", definition } }); } } if (blocks.length > 0) pendingSystemMessages.push({ role: "system", content: blocks });tool_addition mới, không cần gỡ trước. Từ 1.0.1 điều này giữ được cache; trước đó, định nghĩa lại buộc gửi lại cả danh sách (CHANGELOG.md:302).OpenAI Responses: additional_tools hoặc tool search giả lập#
/** * How a Responses transport expresses tool changes. * * `additional_tools` items accumulate: a later item adds tools and replaces same-name * definitions from earlier items, but it cannot replace a top-level tool and cannot remove any * tool (verified with strict schemas on the OpenAI, Codex, and Copilot backends, October 2026). * Like Codex, every tool is therefore declared in `additional_tools` items, starting with one * at the head of the input, and removals are announced as text. Synthetic tool search can * only add tools. Without mid-conversation system messages there is nothing to anchor. */export function getResponsesToolSupport(options: { supportsMidConvoSystemMessages?: boolean; supportsAdditionalTools?: boolean; supportsToolSearch?: boolean;}): TranscriptToolSupport { if (options.supportsMidConvoSystemMessages && options.supportsAdditionalTools) return "changes"; return options.supportsToolSearch ? "additions" : "none";}additional_tools sau không thay được tool cấp request và không gỡ được tool nào.Hai cờ supportsAdditionalTools và supportsToolSearch (compat-schema.ts:381–392) chọn mức. Mỗi system message nằm sau phần đã gộp được chuyển thành item đầu vào:
const appendSystemToolAdditions = (message: SystemMessage, seed: string): void => { const tools = message.toolsAdded ?? []; for (const tool of tools) unavailableTools.delete(tool.name); if (tools.length === 0) return; if (toolSupport === "changes") { if (declaredAdditionalTools) { messages.push({ role: instructionRole, content: renderIncrementalToolsNotice(unavailableTools) }); } declaredAdditionalTools = true; messages.push({ type: "additional_tools", role: "developer", tools: convertResponsesTools(tools, options?.toolOptions), } satisfies ResponseInputItem); return; } if (toolSupport !== "additions") return; const names = tools.map((tool) => tool.name); const callId = `pi_tool_load_${shortHash(`${seed}:${names.join(",")}`)}`; messages.push({ type: "tool_search_call", call_id: callId, execution: "client", status: "completed", arguments: { query: names.join(" "), limit: names.length }, } satisfies ResponseInputItem); messages.push({ type: "tool_search_output", call_id: callId, execution: "client", status: "completed", tools: convertResponsesTools(tools, { ...options?.toolOptions, toolSearchResult: true }), } satisfies ResponseToolSearchOutputItemParam);};"additions", pi giả lập một lời gọi tool search do client thực thi đã hoàn tất, với kết quả là các định nghĩa mang defer_loading: true (openai-responses-shared.ts:468). call_id là hash của vị trí và tên tool, nên request sau sinh lại đúng item ấy.Gỡ tool không diễn đạt được trên dây Responses, nên ở mức "changes" pi viết một câu thông báo “The following tools are no longer available. Do not call them” và nhắc lại danh sách tool vẫn bị gỡ trước mỗi item additional_tools sau đó (openai-responses-shared.ts:175–189). Ghi chú ở đó cho biết vì sao: thiếu lời nhắc, các mô hình nhỏ đọc item mới nhất như bộ tool đầy đủ hoặc gọi lại tool đã gỡ. Đây là bản sửa của 1.4.0 cho lỗi cache bị mất ở mọi lần nạp tool sau một lần gỡ (ai/CHANGELOG.md:36).
| API | System message giữa hội thoại | Thêm tool | Gỡ, định nghĩa lại |
|---|---|---|---|
anthropic-messages | Theo supportsMidConvoSystemMessages, chèn trước assistant message kế tiếp | tool_addition theo giá trị | tool_removal; định nghĩa lại bằng tool_addition mới |
openai-responses, Codex, Azure | Theo cờ, gửi với vai developer hoặc system | additional_tools ("changes") hoặc tool_search_call/output ("additions") | Thông báo bằng văn bản ("changes") hoặc viết lại danh sách đầu một lần ("additions") |
openai-completions (Kimi) | Theo cờ | System message có trường tools khi supportsMidConvoToolAdditions (openai-completions.ts:1235) | Viết lại danh sách đầu ("additions") |
| Google, Vertex, Bedrock | Luôn gộp vào message đầu | Danh sách cấp request là bộ hiện tại ("none") | |
Trace: nạp một tool hoãn lại giữa hội thoại#
Kịch bản: server MCP github được cấu hình "exposure": "deferred", nên extension MCP bật tool_search và tool của server có exposure deferred của pi (mcp/index.ts:501–525). Bộ đang bật là read, bash, edit, write, tool_search. Người dùng hỏi về code trên GitHub.
- Mô hình gọi
tool_searchĐối số{ query: "github search code" }. Tool xếp hạng BM25 các toolcodemode/deferredchưa bật và gọisetActiveTools([...active, "mcp__github__search_code"])(tool-search/tool.ts:195–209). Kết quả tool là văn bản “Loaded 1 tool. They are available from your next call: ...”. _applyToolLoadout()Tool mới được thêm vàoagent.state.tools. Chưa có gì được ghi vào transcript; transcript vẫn khai báo bộ cũ.- Lượt kế tiếp,
declareToolChanges()Không có message chờ, mục prompt không đổi (tool MCP không cópromptSnippet), nên vòng lặp chèn một system message mới{ content: "", toolsAdded: [mcp__github__search_code] }ngay sau kết quả tool. Nó phátmessage_endvà được lưu vào session như mọi message khác (agent-loop.ts:220–225). - pi-ai dựng requestTuỳ provider, xem bảng dưới. Transcript như nhau cho mọi provider; chỉ hình dạng trên dây khác.
- Mô hình gọi thẳng tool mới
mcp__github__search_codegiờ là tool được khai báo, nên lời gọi đi qua pipeline tool bình thường; hooktool_callcủa extension MCP chờ đúng server của nó kết nối xong (mcp/index.ts:1204–1208).
Ví dụ minh hoạ: phần đuôi của request ở bước 4 (rút gọn)
Anthropic, supportsMidConvoToolChanges
tools: [read, bash, edit, write, tool_search(cache_control), __pi_deferred_placeholder__]
messages: ..., assistant [tool_use tool_search], user [tool_result "Loaded 1 tool..."],
system [{ type: "tool_addition", tool: { type: "tool_definition", definition: search_code } }]
→ phần đầu y hệt request trước: trúng cache tới tool_result
OpenAI Responses, mức "changes"
tools: (trống)
input: additional_tools [read, ..., tool_search], developer "<system prompt>", ...,
function_call tool_search, function_call_output,
developer "This is an incremental tools update. ...",
additional_tools [mcp__github__search_code]
OpenAI Responses, mức "additions"
input: ..., function_call_output,
tool_search_call { call_id: "pi_tool_load_…", execution: "client", status: "completed" },
tool_search_output { tools: [search_code (defer_loading: true)] }
Google (gộp)
systemInstruction và danh sách tool dựng lại từ trạng thái hiện tại
→ danh sách tool ở đầu request đổi: trượt cache cho cả hội thoại
Hai chi tiết giữ cho kịch bản này rẻ. Mô tả của tool_search không liệt kê tool hay server nào, nên nó không đổi khi server kết nối (tool-search/tool.ts:211–215); và lần nạp được ghi vào transcript, nên sau resume hay /tree tool vẫn được khai báo trên nhánh đó mà không phải tìm lại.
Làm ấm cache cho Anthropic#
Vấn đề. Cache của Anthropic có tuổi thọ cứng: năm phút với cache_control thường, một giờ với ttl: "1h". Một agent hay chạy tool lâu hơn năm phút: một bộ test chạy lâu, một bản build, một lệnh chờ mạng. Khi tool xong, request kế tiếp gửi lại cả hội thoại, nhưng mục cache đã hết hạn, nên toàn bộ prefix bị tính giá ghi cache thay vì đọc. Với hội thoại vài trăm nghìn token, một lần trượt như vậy đắt hơn nhiều lần so với một request nhỏ giữ cho cache còn sống.
Giải pháp (0.86.0): ngay trước khi mục cache hết hạn, gửi lại đúng request vừa gửi với trần output một token, để giữ mục cache còn sống (cache-warmer.ts:157–161). Thời điểm và tuổi thọ:
/** Streaming warming never continues past this long after the real request that started it. */const MAX_WARMING_AGE_MS = 60 * 60_000;/** Idle warming uses a shorter horizon because continuation estimates become less reliable with age. */const MAX_IDLE_WARMING_AGE_MS = 30 * 60_000;/** A refresh is sent only when it is expected to save at least this many dollars. */const CACHE_WARMING_MINIMUM_EXPECTED_SAVINGS = 0.05;/** * Chance that a real request arrives before the cache entry expires while the * agent sits idle. Measured from our own usage; per-session estimates were not * better than this constant. */const IDLE_CONTINUATION_PROBABILITY = 0.15;/** Refresh at 90% of the TTL while preserving at least ten seconds of margin. */export function getCacheWarmingDelayMs(ttlMs: number): number | undefined { if (ttlMs <= 10_000) return undefined; return Math.max(1, Math.floor(Math.min(ttlMs * 0.9, ttlMs - 10_000)));}/** * Lifetime of the prompt cache entry a request writes, from the model's * `promptCache` tier for the retention the request used. Undefined when the * model has no lifetime for that tier or caching is off. */export function getPromptCacheTtlMs(model: Model<Api>, options: SimpleStreamOptions | undefined): number | undefined { const retention = options?.cacheRetention ?? (getProviderEnvValue("PI_CACHE_RETENTION", options?.env) === "long" ? "long" : "short"); if (retention === "none") return undefined; const seconds = model.promptCache?.[retention]; return seconds === undefined ? undefined : seconds * 1000;}PI_CACHE_RETENTION=long chọn tầng long.Tuổi thọ lấy từ Model.promptCache, trường mới của pi-ai ở 0.86.0 (ai/CHANGELOG.md:288). Chữ “cho Anthropic” trong bài công bố đến từ chỗ này: script sinh catalog chỉ gắn trường đó cho provider Anthropic gọi trực tiếp.
// Anthropic ephemeral entries have a hard five-minute lifetime; `ttl: "1h"`// extends it to one hour. Only direct Anthropic is annotated so cache warming// does not assume equivalent behavior through proxies.// https://docs.anthropic.com/en/docs/build-with-claude/prompt-cachingconst ANTHROPIC_PROMPT_CACHE: ModelPromptCache = { short: 300, long: 3600 };function applyPromptCacheMetadata(model: Model<Api>): void { if (model.provider === "anthropic" && model.api === "anthropic-messages") { model.promptCache = ANTHROPIC_PROMPT_CACHE; } // Do not add OpenAI lifetimes yet. Before enabling warming for explicit // OpenAI caches, re-evaluate it using observed expiry, replay, and billing // behavior; a documented TTL alone does not establish full cache loss.}promptCache thì không bao giờ được làm ấm.Bộ làm ấm được khởi động lại với mỗi request của session, ngay trong streamFn của Agent:
streamFn: async (model, context, options) => { const requestOptions = buildRequestOptions(options); // Compaction and summaries use their own routing ids; only session requests // replace the cache entry, so warming restarts from them. Keep warming while // the current transcript still extends the request's prefix. Agent state may // shallow-copy the messages array or refresh the model object without changing // the provider request, so top-level object identity is not a valid cache key. if (options?.sessionId === sessionManager.getSessionId()) { cacheWarmer.start({ model, context, options: requestOptions }, cacheContextIsCurrent(model)); } return modelRuntime.streamSimple(model, context, requestOptions);},sessionId của session mới khởi động lại bộ làm ấm; theo chú thích ở dòng 414–418, compaction và tóm tắt dùng id định tuyến riêng. cacheContextIsCurrent() (sdk.ts:359–373) dừng việc làm ấm khi người dùng đổi mô hình hoặc transcript không còn nối tiếp request đó, chẳng hạn sau /tree.Mỗi lần hẹn giờ tới, refresh() kiểm tra lại, tính kinh tế, hỏi extension, rồi mới gửi:
private async refresh(run: ActiveRun): Promise<void> { run.timer = undefined; if (!this.validateRun(run)) return; if (this.refreshDeadlineMissed(run)) return; const decision = this.evaluate(run); const { warmCost, missCost, continuationProbability } = decision; let action = decision.action; try { action = await this.decide({ type: "cache_warming_decision", warmCost, missCost, continuationProbability, action, }); } catch { // Extension failures fall back to pi's own decision. } if (!this.validateRun(run) || this.refreshDeadlineMissed(run)) return; const extensionOverride = action !== decision.action; run.extensionOverride = extensionOverride; try { const message = await this.models .streamSimple(run.model, run.context, { ...run.options, maxTokens: 1, maxRetries: 0, signal: run.controller.signal, }) .result(); if (!this.validateRun(run)) return; if (message.stopReason !== "error" && message.stopReason !== "aborted") { const entry = this.sessionManager.appendUsage( "cache_warm", message.provider, message.responseModel ?? message.model, message.usage, extensionOverride ? "extension override" : undefined, ); this.onWarmed?.(entry); } } catch { // Cache warming is best-effort and must not affect the active agent run. } if (this.run === run) this.schedule(run);}maxRetries: 0 và lỗi bị nuốt: làm ấm là best-effort, không bao giờ được ảnh hưởng tới lần chạy thật. Kết quả thành công được ghi thành entry usage loại "cache_warm".private evaluate(run: ActiveRun): CacheWarmingDecision { const model = run.model; const promptTokens = lastPromptTokens(this.sessionManager.getBranch()); const cacheHitCost = price(model, { cacheRead: promptTokens }); const cacheMissCost = price( model, model.cost.cacheWrite > 0 ? { cacheWrite: promptTokens } : { input: promptTokens }, ); const warmCost = price(model, { cacheRead: promptTokens, output: 1 }); const missCost = Math.max(0, cacheMissCost - cacheHitCost); const continuationProbability = run.phase === "idle" ? IDLE_CONTINUATION_PROBABILITY : 1; const economicsAvailable = promptTokens > 0 && (cacheHitCost > 0 || cacheMissCost > 0); const expectedSavings = continuationProbability * missCost - warmCost; return { phase: run.phase, warmCost, missCost, continuationProbability, expectedSavings, economicsAvailable, action: expectedSavings >= CACHE_WARMING_MINIMUM_EXPECTED_SAVINGS ? "warm" : "stop", };}streaming) thì xác suất có request thật tiếp theo coi là 1; lúc rảnh (idle) là 0,15, một hằng số đo từ dữ liệu sử dụng của chính nhóm phát triển (cache-warmer.ts:21–26).Một phép tính với giá giả định cho thấy vì sao có hai pha. Giả sử prompt dài 100.000 token, đọc cache 0,30 USD/triệu token, ghi cache 3,75 USD/triệu token. Một lần làm ấm tốn khoảng 0,03 USD; một lần trượt tốn thêm 0,375 − 0,03 = 0,345 USD. Khi agent đang chạy, tiết kiệm kỳ vọng là 0,345 − 0,03 ≈ 0,315 USD, làm ấm. Khi rảnh, 0,15 × 0,345 − 0,03 ≈ 0,022 USD, dưới ngưỡng, dừng. Với prompt 300.000 token cùng giá, pha rảnh cho 0,15 × 1,035 − 0,09 ≈ 0,065 USD, lại đáng làm ấm. Nghĩa là làm ấm lúc rảnh chỉ đáng với hội thoại rất dài.
Các giới hạn an toàn: làm ấm trong lúc chạy không kéo quá 60 phút kể từ request thật, lúc rảnh không quá 30 phút; một bộ hẹn giờ chạy trễ quá nửa khoảng dự phòng (máy ngủ, event loop bị chặn) thì dừng, vì lần làm mới trễ nhiều khả năng là một lần ghi cache giá đầy đủ (cache-warmer.ts:284–298). Request không phát lại an toàn được thì không làm ấm: với thinking theo ngân sách (Claude không có adaptive thinking), budget_tokens suy ra từ max_tokens, nên một bản phát lại có max_tokens: 1 mang ngân sách khác, và Anthropic khoá cache theo nó. Vì vậy request Anthropic có bật suy nghĩ chỉ được làm ấm khi mô hình có compat.forceAdaptiveThinking (:48–58).
Chế độ nằm trong setting toàn cục cacheWarming: "off", "streaming" (mặc định, chỉ trong lúc chạy) hoặc "idle" (cả giữa các lần chạy) (docs/settings.md:21–23). Chi phí làm ấm vào tổng chi phí session nhưng không vào context của mô hình, vì nó là một entry riêng chứ không phải message:
export interface UsageEntry extends SessionEntryBase { type: "usage"; /** Arbitrary usage category, such as "cache_warm". */ kind: string; provider: string; model: string; usage: Usage; /** Optional human-readable qualifier for usage notices. */ note?: string;}Virtual model#
Vấn đề. Người dùng muốn chọn một mô hình “auto” và để phần mềm chọn mô hình thật cho từng request: câu hỏi nhanh sang mô hình rẻ, việc khó sang mô hình mạnh. Nếu một extension làm điều đó bằng cách đổi mô hình của session, mỗi lần đổi sinh một entry model_change và lựa chọn của người dùng bị ghi đè. Virtual model (0.99.0, thử nghiệm) tách hai khái niệm: lựa chọn được ghi trong model_change, còn mô hình được gửi đi được ghi trên từng assistant message (docs/virtual-models.md:18–25).
export interface ModelRouteRequest<TState = unknown> { /** The selected virtual model. */ model: Model<Api>; /** The selected thinking level. Its meaning is up to the router. */ thinkingLevel: ModelThinkingLevel; reason: ModelRouteReason; /** Physical model and thinking level of the latest successful response in `messages`. */ previous?: { model: Model<Api>; thinkingLevel?: ModelThinkingLevel }; /** * For `retry`: the failed request, which `messages` no longer contains. `message` carries its * `stopReason` and `errorMessage`. Absent when the router itself failed. */ failed?: { model: Model<Api>; thinkingLevel?: ModelThinkingLevel; message: AssistantMessage }; /** Router state last returned on this session branch. Undefined before the first state and for `direct` requests. */ state?: TState; /** Conversation for this request, including system messages. */ messages: readonly Message[]; signal?: AbortSignal;}/** Physical model and thinking level for one request. */export interface ModelRoute<TState = unknown> { model: Model<Api>; thinkingLevel: ModelThinkingLevel; /** * New router state, stored on the session branch unless it is `request.state` itself. Return * `request.state` or undefined to keep the current state. Must be JSON-serializable. Ignored for * `direct` requests. */ state?: TState;}export interface VirtualModelDefinition<TState = unknown> { /** Provider the virtual model is listed under. May be a provider with physical models. */ provider: string; /** Model id. Must not be the id of a physical model of `provider`. */ id: string; name: string; /** Thinking levels offered for selection. Defaults to `["off"]`. */ thinkingLevels?: readonly ModelThinkingLevel[]; /** * Limits shown before the first response. Afterwards, Pi uses the limits of the physical model * that answered. Unset limits are unknown (0). */ contextWindow?: number; maxTokens?: number; /** Input types accepted for selection. Defaults to text and images; routed models without image support get placeholders. */ input?: ("text" | "image")[]; /** Pick the physical model, which must have credentials, and thinking level for one request. */ route(request: ModelRouteRequest<TState>): ModelRoute<TState> | Promise<ModelRoute<TState>>;}reason là "user", "continuation", "retry" hoặc "direct" (:43–50). Extension đăng ký qua pi.registerVirtualModel() với route(request, ctx) có thêm context của extension (extensions/types.ts:1924).Mục catalog của một virtual model có api: "pi-virtual" và giá bằng 0 (virtual-models.ts:169–187); provider bọc nó trả về một luồng lỗi nếu ai đó stream nó mà chưa định tuyến (:189–194). Việc định tuyến diễn ra trong prepareRequest của AgentSession, móc mà chương AgentSession mô tả ở mức tổng quát:
const model = previous?.model ?? this.agent.state.model;const thinkingLevel = previous?.thinkingLevel ?? this.agent.state.thinkingLevel;if (!isVirtualModel(model)) return { ...previous, context, model, thinkingLevel };// The selection stays in agent state; only this request uses the routed model. A routing// failure rejects, which ends the run with an error response. Only messages the user wrote// start a turn; extension messages can follow them, e.g. from before_agent_start.const lastResponse = context.messages.findLastIndex((message) => message.role === "assistant");const userTurn = context.messages.slice(lastResponse + 1).some((message) => message.role === "user");const state = getVirtualModelState(this.sessionManager.getBranch(), model.provider, model.id);const route = await this._modelRuntime.resolveModel(model, convertToLlm(context.messages), { reason: failed ? "retry" : userTurn ? "user" : "continuation", thinkingLevel, signal, failed, state,});if (route.state !== undefined && route.state !== state) { const data: VirtualModelStateData = { provider: model.provider, modelId: model.id, state: route.state }; const entry = this.sessionManager.getEntry( this.sessionManager.appendCustomEntry(VIRTUAL_MODEL_STATE_ENTRY, data), ); if (entry) this._emit({ type: "entry_appended", entry });}// The route stands: the router already decided this request. The state entry does not change// the projection.if (this._exceedsCompactionThreshold(route.model, projection)) { await this._runAutoCompaction("threshold", false); ({ previous, context } = await prepare());}return { ...previous, context, model: route.model, thinkingLevel: route.thinkingLevel };agent.state không đổi; chỉ request này dùng mô hình được chọn. Trạng thái router được lưu thành entry custom pi.virtual-model-state trên nhánh, nên nó đi theo cây session và sống qua compaction. Nếu cửa sổ ngữ cảnh của mô hình được chọn quá nhỏ, pi compaction trước khi gửi, nhưng giữ nguyên lựa chọn của router.ModelRuntime.resolveModel() kiểm tra kết quả của router: phải là mô hình vật lý trong catalog, provider phải có credential, và mức suy nghĩ bị kẹp vào khả năng của mô hình đó (model-runtime.ts:1011–1042). Request ngoài vòng lặp, như tóm tắt compaction, đi qua streamSimple() với reason: "direct"; credential của caller chỉ được chuyển tiếp nếu mô hình được chọn cùng provider với virtual model, để không gửi nhầm khoá cho hãng khác (:733–751).
Ví dụ đầy đủ là jev-router.ts: lập kế hoạch trên mô hình mạnh, viết code trên mô hình rẻ.
export default function (pi: ExtensionAPI) { pi.registerVirtualModel<JevState>({ provider: "jev", id: "auto", name: "Auto (Jev)", thinkingLevels: ["low", "medium", "high", "xhigh"], // Shared by all three models; shown before the first response. contextWindow: 272_000, maxTokens: 128_000, async route(request, ctx) { if (request.reason === "direct") return routeTo(request, ctx, LUNA); const state = request.state; if (!state) { const model = await choosePlanningModel(request, ctx); return routeTo(request, ctx, model, { phase: "planning", model }); } // The planning model made the first edit: hand the rest of the work to Luna. if (state.phase === "planning" && editedThisTurn(request.messages)) { return routeTo(request, ctx, LUNA, { phase: "implementation", model: LUNA }); } return routeTo(request, ctx, state.model); }, });}standard hay complex để chọn Sol hay Terra (jev-router.ts:62–88). Sau lần edit/write thành công đầu tiên, pha chuyển sang Luna và ở đó: đúng một lần trượt cache cho cả session.Router cần tôn trọng cache. Tài liệu khuyên trả lại previous cho continuation và failed cho retry, vì đổi mô hình giữa các lượt làm mất prompt cache và chữ ký thinking (docs/virtual-models.md:84). Khi mở lại session, lựa chọn ảo được khôi phục từ model_change cuối; nếu virtual model không còn đăng ký, pi rơi về mô hình vật lý đã trả lời sau cùng (virtual-models.ts:120–145).
ModelRuntime: mô hình ảnh và classifier#
Ở 0.99.0, pi-ai gộp mô hình ảnh vào cùng bề mặt Provider/Models với mô hình chat (bộ ImagesModels riêng bị gỡ, ai/CHANGELOG.md:199) và thêm một loại thứ ba, classifier: mô hình trả lời các câu hỏi có kiểu về một trạng thái JSON, như Jev của TypeSafe, chứ không sinh văn bản (:209). Kiểu của mỗi mục catalog quyết định thao tác nào nhận nó:
/** Image-generation model: usable with `generateImages()` only. */export interface ImageModel<TApi extends ImageApi> extends BaseModel<TApi> { type: "image"; /** Output modalities. Always includes `"image"`; `"text"` means the model can also return text blocks. */ output: ("text" | "image")[];}/** Structured classifier model: usable with `classify()` only. */export interface ClassifierModel<TApi extends ClassifierApi> extends BaseModel<TApi> { type: "classifier"; contextWindow: number; /** Compatibility overrides for System One APIs. If not set, auto-detected from provider, baseUrl, and id. */ compat?: TApi extends "typesafe-system-one" | "cloudflare-workers-ai-system-one" ? SystemOneCompat : never;}/** Model shape for each model type. */export interface ModelTypeMap { chat: Model<Api>; image: ImageModel<ImageApi>; classifier: ClassifierModel<ClassifierApi>;}/** What a catalog entry is for. Decides which `Models` operation accepts it. */export type ModelType = keyof ModelTypeMap;/** Anything a provider can list. Narrow with `isModelType()`. */export type AnyModel = ModelTypeMap[ModelType];type, nên catalog và provider cũ chạy nguyên. Các accessor không có hậu tố (getModels(), getAvailable()) vẫn chỉ trả mô hình chat; muốn loại khác thì dùng getModelsOfType().Provider khai báo thao tác nào nó hỗ trợ bằng hai phương thức tuỳ chọn, cả hai cam kết không bao giờ reject:
/** Present when the provider supports dedicated image models. Never rejects. */generateImages?( model: ImageModel<ImageApi>, context: ImagesContext, options?: ImagesOptions,): Promise<AssistantImages>;/** Present when the provider supports structured classifier models. Never rejects. */classify?( model: ClassifierModel<ClassifierApi>, context: ClassifierContext, options?: ClassifierOptions,): Promise<ClassifierResult>;API classifier có sẵn ở v1.4.0 là typesafe-system-one, cloudflare-workers-ai-system-one, llama-cpp-classify (đọc xác suất token kế tiếp của llama-server) và openai-decisions (GPT-6 Luna, từ 1.1.0); API ảnh dựng sẵn là openrouter-images (ai/types.ts:78–88). Kiểu câu hỏi và câu trả lời (choice, score, bool) nằm ở ai/types.ts:675–736.
Phía coding agent, ModelRuntime là collection Models đã cấu hình: catalog dựng sẵn, models.json, provider và virtual model của extension, credential từ AuthStorage (chương AgentSession). Hai thao tác mới đi qua cùng bước giải credential với chat:
async generateImages( model: ImageModel<ImageApi>, context: ImagesContext, options?: ModelsImagesOptions,): Promise<AssistantImages> { try { assertImageModel(model); const prepared = await this.prepareRequest(model, options); if (!prepared.provider.generateImages) { throw new ModelsError("provider", `Provider ${model.provider} does not support image generation`); } return await prepared.provider.generateImages(prepared.model, context, prepared.options as ImagesOptions); } catch (error) { return imageErrorResult(model, error, options?.signal?.aborted); }}async classify( model: ClassifierModel<ClassifierApi>, context: ClassifierContext, options?: ModelsClassifierOptions,): Promise<ClassifierResult> { try { assertClassifierModel(model); assertClassifierInputSupported(model, context); const prepared = await this.prepareRequest(model, options); if (!prepared.provider.classify) { throw new ModelsError("provider", `Provider ${model.provider} does not support classification`); } return await prepared.provider.classify(prepared.model, context, prepared.options as ClassifierOptions); } catch (error) { return classifierErrorResult(model, error, options?.signal?.aborted); }}prepareRequest() (:659–705) giải credential đã lưu, OAuth, khoá runtime và header của models.json. Mọi lỗi, kể cả sai loại mô hình hay classifier không nhận ảnh, thành một kết quả có stopReason: "error", hoặc "aborted" khi tín hiệu huỷ đã bật (model-operations.ts:67).Không có tool nào cho mô hình chat gọi thẳng classifier hay mô hình ảnh. Có hai đường: extension gọi ctx.modelRegistry.classify()/generateImages(), như router Jev ở trên, và script codemode gọi models.classify()/models.generateImages() (chương Codemode). Đường thứ hai là cái bài “You Said No MCP!” minh hoạ: cho Jev chấm giọng điệu của 167 issue đang mở với bốn lời gọi song song, kết quả 156 trung tính, 11 hơi bực bội, không issue nào rất bực bội (You Said No MCP!). Chi phí của các lời gọi đó được cộng vào kết quả tool codemode và tính vào chi phí session (CHANGELOG.md:459).
Toàn màn hình, theme hệ thống và đăng nhập#
Ba thay đổi dễ thấy nhất với người dùng lại ít động tới kiến trúc, nên ở đây chỉ nêu điểm chính; cơ chế vẽ của pi-tui thuộc chương pi-tui.
- Toàn màn hình mặc định. Từ 1.0.0,
tuiModemặc định là"fullscreen"(settings-defaults.ts:62): Pi vẽ trên màn hình phụ (alternate screen) của terminal, tự quản lý cuộn, chọn chữ bằng chuột và tìm kiếm. Muốn giữ cách cũ, ghi nối vào scrollback gốc như năm 2025, đặt"tuiMode": "regular"hoặc chạy--tui-mode regular(CHANGELOG.md:345). - Theme
system(0.99.0, mặc định, docs/settings.md:95). Pi hỏi terminal màu chữ, màu nền và 16 màu ANSI trong một lượt (OSC 10, 11, 4; API mớiTUI.queryTerminalColors(), tui/CHANGELOG.md:97), rồi dựng từng token màu từ “họ màu” và các quy tắc tương phản, trong không gian OKHSL; màu của bảng màu không bao giờ tăng chroma khi đổi độ sáng, nên bảng pastel vẫn pastel (system-theme.ts:1–20, bài There are many themes, but this one is yours). - Đăng nhập. Sign in with ChatGPT cho provider
openai: một luồng OAuth client công khai, gửi token người dùng thẳng tới api.openai.com (openai-chatgpt.ts:1–6); provider OpenAI Codex cũ được đổi tên thành “legacy” (CHANGELOG.md:468). Bản 1.0.0 thêm “Sign in with Radius” kèm cấu hình server MCP của Radius trong một bước (:346), và kiểu đăng nhập copy code cho Anthropic khi trình duyệt chạy trên máy khác (anthropic.ts:200). Anthropic workload identity federation qua biến môi trường của SDK có từ 0.99.2 (CHANGELOG.md:384). OAuth của MCP là chuyện riêng, ở chương Codemode, MCP.
Bộ cài managed, install lock và Nix flake#
Vấn đề. Cài pi-coding-agent bằng npm nghĩa là npm chọn phiên bản cho hàng trăm phụ thuộc bắc cầu vào lúc cài. Ghim chính xác phụ thuộc trực tiếp không đủ: một bản phát hành độc hại của một gói ở tầng sâu vẫn vào được máy người dùng ngay trong ngày nó xuất hiện. Pi chọn coi thay đổi phụ thuộc là code cần review, và ghi điều đó trong README:
## Supply-chain hardeningWe treat npm dependency changes as reviewed code changes.- Direct external dependencies are pinned to exact versions. Internal workspace packages remain version-ranged.- `.npmrc` sets `save-exact=true` and `min-release-age=2` to avoid same-day dependency releases during npm resolution.- `package-lock.json` is the dependency ground truth. Pre-commit blocks accidental lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1` is set.- `npm run check` verifies pinned direct deps, native TypeScript import compatibility, and the generated coding-agent install lock.- The pi.dev installer installs from `packages/coding-agent/install-lock/`, generated from the root lockfile, to pin transitive deps. The npm package does not pin transitive deps.- Local release smoke tests and npm publication use the same tarball packer; npm publishes the validated tarballs rather than repacking workspace directories.- Local release installs, documented npm installs, and `pi update --self` use `--ignore-scripts` where supported.- CI installs with `npm ci --ignore-scripts`, and a scheduled GitHub workflow runs `npm audit --omit=dev` plus `npm audit signatures --omit=dev`.- Install lock generation has an explicit allowlist for dependency lifecycle scripts; new lifecycle-script deps fail checks until reviewed.Gói npm cố ý không ghim bắc cầu: 1.0.1 gỡ npm-shrinkwrap.json khỏi gói để thư viện dùng lại pi có thể override phụ thuộc, và chuyển việc ghim sang bộ cài của pi.dev (CHANGELOG.md:322). Bộ cài đó dùng một lockfile riêng, sinh từ lockfile gốc của monorepo:
{ "name": "@earendil-works/pi-coding-agent-install", "version": "1.4.0", "private": true, "description": "Lockfile root used by the Pi installer and updater.", "dependencies": { "@earendil-works/pi-coding-agent": "1.4.0" }, "overrides": { "protobufjs": "7.6.6" }, "engines": { "node": ">=22.19.0" }}package-lock.json đầy đủ trong cùng thư mục, mỗi gói có resolved và integrity.Script sinh lockfile đi theo đồ thị phụ thuộc từ @earendil-works/pi-coding-agent trong lockfile gốc, chép từng entry và thay workspace nội bộ bằng tarball trên registry (generate-coding-agent-install-lock.mjs:371–420). Phần đáng chú ý là kiểm tra cuối: gói nào có lifecycle script mà chưa nằm trong danh sách cho phép thì thất bại, và một mục cho phép không còn dùng cũng thất bại:
const allowedInstallScriptPackages = new Map([ ["@google/genai@2.21.0", "preinstall is a no-op in the published package"], ["protobufjs@7.6.6", "postinstall only warns about protobufjs version scheme mismatches"],]); if (entry.hasInstallScript) { if (!packageName || !entry.version) { errors.push(`${lockPath || "root"} has install scripts but no package name/version`); } else { const packageId = `${packageName}@${entry.version}`; if (allowedInstallScriptPackages.has(packageId)) { seenAllowedInstallScriptPackages.add(packageId); } else { errors.push( `${lockPath} has install scripts (${packageId}). Review it and add it to allowedInstallScriptPackages if intentional.`, ); } } } } for (const packageId of allowedInstallScriptPackages.keys()) { if (!seenAllowedInstallScriptPackages.has(packageId)) { errors.push(`allowed install-script package ${packageId} is no longer present; remove it from the allowlist`); } }npm run check chạy script này với --check, nên một thay đổi lockfile quên sinh lại install lock không qua được kiểm tra.Phía người dùng, bộ cài managed đặt mỗi bản phát hành trong một thư mục releases/<version> dưới gốc PI_MANAGED_INSTALL_ROOT, đánh dấu bằng managed-install.json (package-manager-cli.ts:55–80). pi update trên bản cài này làm như sau:
const releaseUrl = `${installerApiBase}/${encodeURIComponent(version)}`;const stagingRoot = join(managedRoot, "staging");const releasesRoot = join(managedRoot, "releases");mkdirSync(releasesRoot, { recursive: true });const releaseDir = join(releasesRoot, version);if (existsSync(releaseDir)) { verifyManagedRelease(releaseDir, version); activateManagedRelease(managedRoot, version); pruneManagedReleases(managedRoot, version); return;}mkdirSync(stagingRoot, { recursive: true });stageDir = mkdtempSync(join(stagingRoot, "update-"));const [packageJsonContent, packageLockContent] = await Promise.all([ fetchInstallerArtifact(`${releaseUrl}/package.json`, "package.json"), fetchInstallerArtifact(`${releaseUrl}/package-lock.json`, "package-lock.json"),]);writeFileSync(join(stageDir, "package.json"), packageJsonContent);writeFileSync(join(stageDir, "package-lock.json"), packageLockContent);await runManagedNpmCi(stageDir);verifyManagedRelease(stageDir, version);renameSync(stageDir, releaseDir);activateManagedRelease(managedRoot, version);pruneManagedReleases(managedRoot, version);npm ci --ignore-scripts cài đúng cây đã khoá, không chạy lifecycle script (:90–104). Bản mới chỉ được kích hoạt sau khi pi --version của nó trả đúng số phiên bản, bằng cách ghi nguyên tử file current-version.Cả quá trình nằm trong một khoá file, nên hai phiên chạy pi update cùng lúc không giẫm lên nhau. Từ 1.1.0, sau khi cập nhật chỉ giữ bản mới và bản vừa chạy lệnh cập nhật, để các phiên khác đang mở dùng tiếp và để quay lại được bằng cách sửa current-version (:138–156, CHANGELOG.md:198).
Nix flake (1.0.1): nix run github:earendil-works/pi/stable chạy bản mới nhất, nix profile add cài nó (CHANGELOG.md:284). Flake chỉ xuất package pi (cũng là default), app tương ứng và một overlay, cho bốn hệ macOS và Linux (flake.nix:20–53). Phần đáng học nằm trong nix/package.nix, dùng lại đúng install lock của bộ cài pi.dev và ghim catalog mô hình bằng hash nội dung:
let nodejs = nodejs_22; packageJson = lib.importJSON (source + "/packages/coding-agent/package.json"); # Lockfile root used by the pi.dev installer. It pins the coding agent's # runtime dependency tree and is kept in sync with package-lock.json by # `npm run check`. installLock = source + "/packages/coding-agent/install-lock"; modelCatalogPin = lib.importJSON ./model-catalog.json; modelCatalog = fetchurl { name = "pi-model-catalog.json"; # The typed catalog is the representation whose bytes the revision hashes. url = "https://pi.dev/api/models/revisions/${modelCatalogPin.revision}?types=chat,image,classifier"; sha256 = lib.removePrefix "sha256-" modelCatalogPin.revision; };Cài qua Nix thì pi update không cập nhật được; dùng nix profile upgrade pi (docs/quickstart.md:23–29). Bản 1.3.0 sửa hai lỗi của gói Nix đáng nhớ: build hỏng khi một provider đổi API so với catalog đã ghim, và Node của Pi đứng đầu PATH khiến shell của tool bash dùng Node của Pi thay vì của người dùng (CHANGELOG.md:87–88).
Pi Durable phát hành cùng lúc#
Cùng ngày 1.0.0, @earendil-works/pi-durable có bản phát hành đầu tiên (durable/CHANGELOG.md:185), với dòng cảnh báo đầu README: “Experimental. The API changes without notice between releases.” (durable/README.md:3). Bài công bố gọi nó là “a new substrate for building long-running agentic applications” (Pi 1.0).
Quan hệ với coding agent được thể hiện rõ nhất qua phụ thuộc. Coding agent dựa trên pi-agent-core, pi-ai, pi-codemode, pi-mcp và pi-tui (coding-agent/package.json:44–48), không dùng Durable hay Chord. Durable dựa trên Chord, pi-ai, pi-codemode và pi-telemetry (durable/package.json:153–156), không dùng pi-agent-core. Con đường tới đó thấy được trong git: harness bền mang tên mã Pico được dời từ pi-agent-core sang gói riêng ngày 2026-09-18 (commit 080160162), harness thử nghiệm còn lại bị gỡ khỏi pi-agent-core ở 1.0.0 (commit 7fd478a2e), và 1.2.0 gỡ nốt bộ server, client, plugin thử nghiệm cùng phụ thuộc Chord của coding agent (CHANGELOG.md:165–166).
Hai sản phẩm chia sẻ một thứ đáng chú ý ngoài pi-ai: pi-codemode. Lần chạy tất định sandbox.start() mà 1.2.0 thêm vào gói (codemode/CHANGELOG.md:31) là thứ Pi Durable dùng để phát lại một script sau khi tiến trình chết (durable/src/codemode/codemode.ts:465); coding agent chỉ dùng execute(). Mô hình khái niệm của Durable ở chương Pi Durable, còn codemode bền ở chương Durable: task, scheduler, generation và tool.
Sau 1.0: các thay đổi kiến trúc của 1.1 tới 1.4#
Bốn bản sau 1.0 tiếp tục hai mạch ý ở đầu chương, cộng thêm việc đo đạc và nhận diện client.
Đo thời gian và trạng thái (1.1.0)#
AssistantMessageEventStream đo mỗi phản hồi bằng đồng hồ đơn điệu từ lúc bắt đầu request tới message cuối, ghi vào AssistantMessage.durationMs. Phép đo nằm trong chính lớp luồng, và cùng bản đó mọi hàm stream bắt buộc trả về đúng lớp này (ai/CHANGELOG.md:88–93). Kết quả tool có durationMs của riêng execute(), không tính hook (agent/CHANGELOG.md:17). Terminal của pi-tui phải cài setProgramStatus() để báo trạng thái qua OSC 7501 cho terminal và bảng điều khiển agent (tui/CHANGELOG.md:44).
Tool chạm tới tool khác, sandbox ngoài Node (1.2.0)#
reachesTools và alias của namespace (CHANGELOG.md:124–125) cho extension MCP biết một lời gọi codemode hay tool_search cần chờ server nào. before_agent_start chạy cả cho lần chạy do pi.sendMessage(..., { triggerTurn: true }) kích hoạt, nên các mục prompt thêm trong handler không còn bị gỡ giữa lần chạy, vốn làm trượt cache hai lần mỗi lần chạy (CHANGELOG.md:143). pi-codemode có RemoteCodemodeSandbox, InlineCodemodeSandbox cho Cloudflare và môi trường không có worker thread (codemode/CHANGELOG.md:28–29).
Đổi mức suy nghĩ và đổi mô hình mà giữ cache (1.3.0)#
Mạch “nối thêm” lan sang mức suy nghĩ. Trên GPT-6 với API key, mức suy nghĩ không còn đổi trường reasoning.effort của request mà được gửi thành item configuration_update nằm trong input:
/** * Effort for the reply being generated. When set, the thinking level is sent as * configuration_update items instead of a changing reasoning.effort, which would invalidate the * prompt cache. Each item sets the effort from that point on: one goes before every earlier * reply with the level recorded on it (`providerThinkingLevel`), and one with this effort goes * at the end. Earlier input therefore never changes. OpenAI documents the item before user * messages only. After tool results it was verified on gpt-6-luna (October 2026) to be accepted * and to apply to the next reply. */configurationUpdateEffort?: string;output_config mà adapter Anthropic dùng cho mô hình hỗ trợ đổi effort giữa hội thoại (chương pi-ai).Kimi Code chuyển sang Chat Completions để tool thêm giữa hội thoại không phá cache (ai/CHANGELOG.md:52). Khi đổi mô hình, khối thinking do mô hình khác sinh ra bị bỏ thay vì gửi thành văn bản, vì mô hình mới từng đọc chúng như một phần câu trả lời và bắt đầu viết suy nghĩ của nó ra phần hiển thị (:53). Codemode đếm worker tới khi chúng thực sự thoát, không chỉ tới khi kết quả trả về (codemode/CHANGELOG.md:13).
Danh tính client, telemetry và các mức hỗ trợ tool (1.4.0)#
pi-ai nhận một identity quyết định User-Agent và các header attribution cho OpenRouter, NVIDIA NIM, OpenCode, đặt được cho cả createModels() (ai/CHANGELOG.md:13); Agent có identity tương ứng (agent/CHANGELOG.md:7). Request được bọc trong span telemetry không chứa nội dung (ai/CHANGELOG.md:16–17, xem chương pi-env, telemetry). Cờ boolean của resolveTranscriptTools() thành TranscriptToolSupport ba mức đã đọc ở trên. Classifier nhận ảnh, mỗi dịch vụ một dạng trên dây (ai/CHANGELOG.md:20). Script codemode xếp hàng chờ worker rảnh thay vì thất bại ngay (CHANGELOG.md:40), và tóm tắt compaction dùng giới hạn output của mô hình thay cho trần cố định (:33).
Quyết định thiết kế#
Transcript là nguồn sự thật cho prompt và bộ tool. Phương án đơn giản là mỗi request mang system prompt và danh sách tool hiện tại. Pi chọn ghi chúng vào transcript: tài liệu extension nói thẳng rằng prompt và bộ tool ban đầu nằm trong system message đầu, các thay đổi được nối thêm trước request kế tiếp, và provider nào không biểu diễn được bước chuyển thì nhận một “transcript checkpoint” đầy đủ, chấp nhận mất cache (docs/extensions.md:301). Được: resume, /tree và fork khôi phục đúng loadout của nhánh; provider nào có khả năng thì giữ được cache. Mất: adapter phức tạp hơn nhiều, và khả năng của từng mô hình phải được kiểm chứng bằng thực nghiệm. Các cờ compat ghi rõ catalog chỉ bật chúng “for verified models”, và mã nguồn có nhiều ghi chú “verified October 2026” (compat-schema.ts:303).
Khai báo và gọi được là hai trục riêng. Năm 2025 Mario từ chối MCP vì mô tả tool của nó chiếm một phần đáng kể cửa sổ ngữ cảnh ngay từ đầu (chương Lịch sử). Exposure giải quyết đúng chỗ đó: tool deferred gọi được mà không tốn token nào trong phần khai báo. Sự tồn tại của cả một server chỉ tốn một dòng trong mục mcp_servers. Cái giá là mô hình phải tìm tool trước khi dùng, bằng tool_search hoặc searchTools() trong script.
Mô tả ổn định quan trọng hơn mô tả đầy đủ. Mô tả codemode liệt kê tool theo exposure chứ không theo bộ đang bật, nên tool_search nạp tool không làm khai báo lại codemode (codemode/tool.ts:317–318); mô tả tool_search không liệt kê gì; danh sách server đi vào một mục prompt được nối thêm khi đổi. Ba lựa chọn cùng một mục đích: phần đầu request không đổi khi server MCP kết nối hay ngắt.
Làm ấm có tính toán, và chỉ ở nơi đã kiểm chứng. Làm ấm mọi lúc tốn tiền vô ích khi người dùng đã bỏ đi; dùng mặc định cache một giờ thì bài “Prompt Caching In Agents” cho rằng chi phí thêm thường không đáng ở giá API. Pi chọn một quyết định kinh tế cho từng lần làm mới, ngưỡng 0,05 USD, hằng số đo từ dữ liệu thật, và một sự kiện cache_warming_decision cho extension ghi đè. Nó cũng không làm ấm qua proxy, và hoãn làm ấm OpenAI cho tới khi đo được hành vi hết hạn, phát lại và tính tiền.
Lựa chọn tách khỏi việc gửi đi. Virtual model cho phép định tuyến mà không viết lại lịch sử lựa chọn của người dùng, và đặt trạng thái router trên cây session để nó đi theo nhánh. Phương án extension tự đổi mô hình bị loại vì nó trộn hai loại thông tin vào cùng entry model_change.
Ghim cho người dùng cuối, không ghim cho thư viện. Gói npm để phụ thuộc bắc cầu tự do để người nhúng pi còn override được; bộ cài pi.dev và gói Nix dùng install lock đã kiểm tra. Hai nhu cầu khác nhau, hai kênh khác nhau.
Hai sản phẩm thay vì một. Thay vì đưa tính bền vào coding agent, Earendil viết một harness riêng và để coding agent giữ vòng lặp đơn giản của nó. Việc gỡ harness cũ khỏi pi-agent-core ở 1.0.0 là hệ quả trực tiếp.
Cạm bẫy và trường hợp biên#
- Provider tự viết phải đọc từ transcript. Từ 0.86.0, hàm stream nhận
TranscriptContext; system prompt và tool lấy bằnggetCurrentSystemPrompt()vàgetCurrentTools(), không còn trongcontext.systemPrompt(ai/CHANGELOG.md:279). - Handler
contextkhông thấy system message. Từ 0.87.0 pi khôi phục prompt và trạng thái tool sau khi handler chạy, vì handler lọc message từng làm mất cả khai báo tool. Cần biến đổi toàn bộ transcript thì dùngcontext_with_system(CHANGELOG.md:561). - Tool không gỡ đăng ký được. Muốn rút một tool, đăng ký lại nó với
exposure: "hidden"; extension MCP làm đúng như vậy với tool mà server bỏ đi (docs/extensions.md:273, mcp/index.ts:447–452). - Vị trí trên dây khác vị trí trong transcript. Với Anthropic, một bản cập nhật đặt trước user message trong transcript sẽ nằm sau user message đó trên dây (anthropic-messages.ts:1367–1368).
- Tool bị gỡ trên OpenAI Responses vẫn được khai báo. Ở mức
"changes", việc gỡ chỉ được thông báo bằng văn bản. Nếu mô hình vẫn gọi, vòng lặp trả kết quả lỗi “Tool ... not found”, vì tool không còn trong bộ chạy được (agent-loop.ts:726–732). - Đổi sang provider phải gộp system message làm mất cache một lần cho cả hội thoại, cũng như mọi lần đổi mô hình. Router virtual model nên dính vào mô hình của lượt hiện tại.
- Không có
promptCachethì không có làm ấm. Mô hình Claude gọi qua OpenRouter hay Bedrock không được làm ấm;cacheWarminglà setting toàn cục, không đặt theo dự án được. BậtshowCacheMissNoticesđể thấy thông báo làm ấm và trượt cache (docs/settings.md:20). - Virtual model chưa có giới hạn ngữ cảnh trước phản hồi đầu, trừ khi định nghĩa khai báo; giới hạn chưa khai báo là 0, tức chưa biết (virtual-models.ts:92–97).
- Cài qua Nix thì
pi updatekhông cập nhật được, và một gói Nix cũ có thể mang catalog mô hình cũ đã ghim.
Đọc code ở đâu#
| # | File | Đọc để hiểu |
|---|---|---|
| 1 | coding-agent/CHANGELOG.md | Từ ## [0.86.0] đi lên: mục New Features của mỗi bản là bản đồ tính năng; mục Breaking Changes là nơi kiến trúc đổi. |
| 2 | ai/src/utils/transcript.ts | Phát lại system message, gộp, so bộ tool, TranscriptToolSupport. Ngắn, đọc hết. |
| 3 | agent/src/agent-loop.ts:332–384 | Cầu nối giữa bộ chạy được và transcript. |
| 4 | extensions/types.ts:501–668 | ToolExposure, ToolNamespace, ToolLoadout, ToolDefinition. |
| 5 | agent-session.ts:1498–1640, :1748–1861 | Loadout, bản vá prompt, phép chiếu, khôi phục từ transcript. |
| 6 | system-prompt.ts:127–229 | Các mục prompt có tên và phép diff. |
| 7 | anthropic-messages.ts:1202–1276, :1353–1399 | Tool cố định cộng placeholder, tool_addition/tool_removal. |
| 8 | openai-responses-shared.ts:156–300 | additional_tools, tool search giả lập, thông báo gỡ tool, configuration_update. |
| 9 | core/cache-warmer.ts, sdk.ts:333–423 | Toàn bộ vòng đời làm ấm cache và chỗ nó được nối vào Agent. |
| 10 | core/virtual-models.ts, docs/virtual-models.md, jev-router.ts | Kiểu dữ liệu, tài liệu, một router thật. |
| 11 | model-runtime.ts:659–832, ai/src/models.ts:158–247 | Chat, ảnh, classifier đi qua cùng bước giải credential. |
| 12 | README.md:168–180, generate-coding-agent-install-lock.mjs, package-manager-cli.ts:48–244, nix/package.nix | Chuỗi cung ứng: install lock, cập nhật managed, Nix. |
Tóm tắt#
- Pi 1.0 (2026-10-01) là mốc ổn định; phần lớn tính năng của bài công bố đã vào ở 0.86.0 (system message, làm ấm cache) và 0.99.0 (codemode, MCP, exposure, virtual model, classifier, theme
system). - Mạch kiến trúc chính là “nối thêm thay vì viết lại”: prompt và bộ tool được ghi thành phần chênh lệch ở cuối transcript, và pi-ai gửi chúng tại chỗ với Anthropic, OpenAI Responses, Kimi, hoặc gộp lại với provider không hỗ trợ; đổi mức suy nghĩ trên mô hình hỗ trợ cũng không sửa phần đầu.
ToolExposuretách “được khai báo” khỏi “gọi được”;prepareLoadout()cho tool điều phối sửa mô tả và giấu khai báo; transcript là nguồn sự thật của loadout qua resume và/tree.- Làm ấm cache gửi lại request với trần một token trước khi cache Anthropic hết hạn, chỉ khi tiết kiệm kỳ vọng từ 0,05 USD, và ghi chi phí thành entry
cache_warm. - Virtual model tách lựa chọn khỏi mô hình được gửi đi; catalog có thêm mô hình ảnh và classifier, dùng từ extension hoặc script codemode.
- Bộ cài pi.dev và gói Nix ghim cả cây phụ thuộc bằng install lock; gói npm thì không. Pi Durable ra mắt cùng ngày như một gói riêng, thử nghiệm, không phụ thuộc
pi-agent-core.