Pi từ mã nguồnpi v1.4.0

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.

  • 25 phút đọc
  • 34 đoạn code
  • 19 mục
Trong chương này19 mục
  1. Vai trò: “Pi 1.0” nghĩa là gì
  2. Dòng thời gian phát hành
  3. Mô hình tư duy: prefix ổn định và mô hình ngoài chat
  4. Data model: exposure, namespace và loadout
  5. AgentSession áp dụng loadout
  6. Thông điệp hệ thống giữa hội thoại
  7. pi-ai đưa thay đổi tool lên dây
  8. Trace: nạp một tool hoãn lại giữa hội thoại
  9. Làm ấm cache cho Anthropic
  10. Virtual model
  11. ModelRuntime: mô hình ảnh và classifier
  12. Toàn màn hình, theme hệ thống và đăng nhập
  13. Bộ cài managed, install lock và Nix flake
  14. Pi Durable phát hành cùng lúc
  15. Sau 1.0: các thay đổi kiến trúc của 1.1 tới 1.4
  16. Quyết định thiết kế
  17. Cạm bẫy và trường hợp biên
  18. Đọc code ở đâu
  19. 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ảnCơ chế chínhĐọc ở
System message giữa hội thoại0.86.0 (CHANGELOG.md:609)SystemMessage trong transcript, declareToolChanges(), diff các mục promptThông điệp hệ thống, lên dây
Làm ấm cache cho Anthropic0.86.0 (:619)CacheWarmer, Model.promptCacheLàm ấm cache
Codemode và MCP0.99.0 (:440)Gói pi-codemode, pi-mcp, ba built-in extensionchương Codemode, MCP
Nạp tool hoãn lại0.99.0 (:441)exposure, prepareLoadout(), tool_searchData model, loadout
Virtual model0.99.0, thử nghiệm (:443)registerVirtualModel(), router mỗi requestVirtual model
Mô hình Jev và mô hình ảnh0.99.0 (:451–452), codemode sinh ảnh ở 1.0.0ModelType, classify(), generateImages()ModelRuntime
Theme mới, toàn màn hình0.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ảnNgàyThay đổi kiến trúc
0.86.02026-09-19System 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.02026-09-21SessionManager 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.02026-09-29Codemode, 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.209-29, 09-30Server 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.02026-10-01Toà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.12026-10-03Nix 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.410-04, 10-05samplingParamsByThinkingLevel; đổi tên provider azure; mẫu * trong --tools, --no-mcp; built-in của codemode bị đóng băng; ToolLoadout.getPromptGuidelines().
1.1.02026-10-07Bá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.02026-10-12reachesTools, 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.02026-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.02026-10-21Client 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)
Đường đi của một thay đổi tool hoặc prompt, từ lúc đăng ký tới request gửi provider.

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 SystemMessage nằ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:

ToolExposure: năm cách mô hình tiếp cận một tool
pi/packages/coding-agent/src/core/extensions/types.tsdòng 501–516TypeScript
/** * 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";
Câu cuối của chú thích là định nghĩa quan trọng nhất: bộ tool đang bật (getActiveTools/setActiveTools) chính là bộ được khai báo cho mô hình.
exposureKhai báo cho mô hìnhGọi được qua ctx.executeToolBật khi đăng kýMô tả codemode liệt kêtool_search tìm được
direct (mặc định)khi đang bậtkhi đang bậtcóchỉ với codemode.mode: "only"không
model-onlykhi đang bậtkhông bao giờcókhôngkhông
codemodechỉ khi bật tường minhluôn, khi đã đăng kýkhôngcócó
deferredchỉ khi bật tường minhluôn, khi đã đăng kýkhôngkhôngcó
hiddenkhôngkhôngkhông (bật cũng vô hiệu)khôngkhô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:

ToolLoadout và ToolLoadoutChanges
pi/packages/coding-agent/src/core/extensions/types.tsdòng 551–574TypeScript
/** 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:

ToolDefinition: các trường thêm từ 0.99
pi/packages/coding-agent/src/core/extensions/types.tsdòng 600–638TypeScript
/** * 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:

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:

Phép chiếu lọc hiddenDeclarations khỏi mọi system message
pi/packages/coding-agent/src/core/agent-session.tsdòng 1782–1805TypeScript
/** * 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ọc cả transcript bằng tập hiện tại, nên phần khai báo mà provider thấy chỉ đổi khi loadout đổi, chứ không đổi ngẫu nhiên giữa các request.

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:

_restoreToolsFromTranscript(): transcript là nguồn sự thật của loadout
pi/packages/coding-agent/src/core/agent-session.tsdòng 1830–1861TypeScript
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);}
Tool có tên trong transcript nhưng chưa đăng ký, điển hình là tool MCP của server đang kết nối, được giữ trong _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:

_preparePromptAndToolLoadout(): áp loadout và trả về bản vá mục prompt
pi/packages/coding-agent/src/core/agent-session.tsdòng 1748–1770TypeScript
/** * 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;}
Bản vá được đặt lên đầu các message của lần chạy (:1973–1975) hoặc vào danh sách message chuẩn bị cho lượt sau (:915–927).
diffSystemPromptSections(): chỉ các mục đổi, null cho mục bị xoá
pi/packages/coding-agent/src/core/system-prompt.tsdòng 212–229TypeScript
/** * 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ỏ:

withToolChanges(): danh sách rỗng thì bỏ hẳn trường
pi/packages/agent/src/agent-loop.tsdòng 374–384TypeScript
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 } : {}),	};}
Bỏ trường thay vì để mảng rỗng giữ system message gọn. Khi không có gì đổi và message của caller cũng không khai báo tool, vòng lặp trả lại nguyên object đó (agent-loop.ts:363–364). Phân tích từng dòng của 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?"
Message [0]–[4] không bị sửa. Thay đổi của prompt thứ hai là một message [5] chèn trước câu hỏi mới.

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:

renderSystemMessageUpdate(): cách một bản vá mục prompt được viết ra
pi/packages/ai/src/utils/text.tsdòng 23–40TypeScript
/** * 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");}
Chú thích nói rõ khung này chỉ tồn tại lúc gửi request và có thể đổi giữa các phiên bản; transcript lưu dạng có cấu trúc.

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:

collapseSystemMessages() và resolveTranscript()
pi/packages/ai/src/utils/transcript.tsdòng 104–120TypeScript
/** * 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):

TranscriptToolSupport và resolveTranscriptTools()
pi/packages/ai/src/utils/transcript.tsdòng 221–230, 242–263TypeScript
/** * 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:

Anthropic: bộ tool ban đầu cố định cộng một placeholder defer_loading
pi/packages/ai/src/api/anthropic-messages.tsdòng 201–213, 1202–1204, 1264–1276TypeScript
/** * 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);		}	}
Chú thích ghi lại một phép đo: không có placeholder thì lần đổi tool đầu tiên trượt cache hoàn toàn, vì Anthropic thêm phần khung prompt ẩn cho thay đổi tool giữa hội thoại; khai báo placeholder từ request đầu đưa khung đó vào prefix đã cache.

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):

Một system message thành khối tool_removal và tool_addition
pi/packages/ai/src/api/anthropic-messages.tsdòng 1378–1399TypeScript
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 });
Định nghĩa lại một tool cùng tên chỉ cần một 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#

Chọn mức hỗ trợ cho các transport Responses
pi/packages/ai/src/api/openai-responses-shared.tsdòng 156–173TypeScript
/** * 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";}
Ghi chú “verified ... October 2026” là kết quả thử trên backend OpenAI, Codex và Copilot: một item 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:

Một lần thêm tool: additional_tools, hoặc cặp tool_search_call/output
pi/packages/ai/src/api/openai-responses-shared.tsdòng 231–264TypeScript
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);};
Ở mức "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).

APISystem message giữa hội thoạiThêm toolGỡ, định nghĩa lại
anthropic-messagesTheo supportsMidConvoSystemMessages, chèn trước assistant message kế tiếptool_addition theo giá trịtool_removal; định nghĩa lại bằng tool_addition mới
openai-responses, Codex, AzureTheo cờ, gửi với vai developer hoặc systemadditional_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, BedrockLuôn gộp vào message đầuDanh 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.

  1. Mô hình gọi tool_searchĐối số { query: "github search code" }. Tool xếp hạng BM25 các tool codemode/deferred chưa bật và gọi setActiveTools([...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: ...”.
  2. _applyToolLoadout()Tool mới được thêm vào agent.state.tools. Chưa có gì được ghi vào transcript; transcript vẫn khai báo bộ cũ.
  3. 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át message_end và được lưu vào session như mọi message khác (agent-loop.ts:220–225).
  4. 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.
  5. Mô hình gọi thẳng tool mớimcp__github__search_code giờ là tool được khai báo, nên lời gọi đi qua pipeline tool bình thường; hook tool_call củ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
Cùng một transcript, bốn hình dạng trên dây. Ba hình dạng đầu chỉ nối thêm vào cuố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ọ:

Hằng số, thời điểm làm mới và tuổi thọ cache
pi/packages/coding-agent/src/core/cache-warmer.tsdòng 15–46TypeScript
/** 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;}
Với TTL 300 giây, lần làm mới rơi vào giây thứ 270 (90%, còn ít nhất 10 giây dự phòng). 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.

Chỉ Anthropic trực tiếp có tuổi thọ cache, OpenAI cố ý chưa có
pi/packages/ai/scripts/generate-models.tsdòng 1000–1013TypeScript
// 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.}
Qua proxy, pi không giả định hành vi giống Anthropic gọi trực tiếp. Với OpenAI, chú thích ghi rằng một TTL trong tài liệu chưa đủ để kết luận cache mất hẳn khi hết hạn; phải đo hành vi hết hạn, phát lại và tính tiền trước. Mô hình không có 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:

Mỗi request của session khởi động lại CacheWarmer
pi/packages/coding-agent/src/core/sdk.tsdòng 412–423TypeScript
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);},
Chỉ request mang 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:

evaluate(): làm ấm khi tiết kiệm kỳ vọng từ 0,05 USD
pi/packages/coding-agent/src/core/cache-warmer.tsdòng 378–400TypeScript
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",	};}
Đang chạy (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:

UsageEntry: chi phí không thuộc về message nào
pi/packages/coding-agent/src/core/session-manager.tsdòng 80–89TypeScript
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).

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:

prepareRequest(): định tuyến virtual model cho đúng một request
pi/packages/coding-agent/src/core/agent-session.tsdòng 812–842TypeScript
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 };
Lựa chọn trong 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ẻ.

jev-router: hai pha, một lần đổi mô hình
pi/packages/coding-agent/examples/extensions/jev-router.tsdòng 90–113TypeScript
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);		},	});}
Ở request đầu, Jev phân loại tin nhắn của người dùng thành 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ó:

ImageModel, ClassifierModel và ModelTypeMap
pi/packages/ai/src/types.tsdòng 882–908TypeScript
/** 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];
Mô hình chat được phép bỏ trống 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:

Provider.generateImages() và Provider.classify()
pi/packages/ai/src/models.tsdòng 234–246TypeScript
/** 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:

ModelRuntime.generateImages() và classify(): lỗi thành kết quả
pi/packages/coding-agent/src/core/model-runtime.tsdòng 799–832TypeScript
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, tuiMode mặ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ới TUI.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:

README: Supply-chain hardening
pi/./README.mddòng 168–180Markdown
## 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:

install-lock/package.json: gốc lockfile của bộ cài
pi/packages/coding-agent/install-lock/package.jsondòng 1–15JSON
{	"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"	}}
Kèm theo là một 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:

Danh sách cho phép lifecycle script và phép kiểm tra
pi/scripts/generate-coding-agent-install-lock.mjsdòng 17–20, 319–339JavaScript
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:

runManagedSelfUpdate(): tải lockfile, npm ci, thử chạy, rồi mới chuyển
pi/packages/coding-agent/src/package-manager-cli.tsdòng 214–239TypeScript
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:

nix/package.nix: install lock và catalog mô hình được ghim
pi/nix/package.nixdòng 17–30Nix
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:

configuration_update: mức suy nghĩ thành item đầu vào, phần trước không đổi
pi/packages/ai/src/api/openai-responses-shared.tsdòng 133–142TypeScript
/** * 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;
Cùng ý với các system message chỉ chứa 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ằng getCurrentSystemPrompt() và getCurrentTools(), không còn trong context.systemPrompt (ai/CHANGELOG.md:279).
  • Handler context khô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ùng context_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ó promptCache thì không có làm ấm. Mô hình Claude gọi qua OpenRouter hay Bedrock không được làm ấm; cacheWarming là setting toàn cục, không đặt theo dự án được. Bật showCacheMissNotices để 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 update khô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
1coding-agent/CHANGELOG.mdTừ ## [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.
2ai/src/utils/transcript.tsPhát lại system message, gộp, so bộ tool, TranscriptToolSupport. Ngắn, đọc hết.
3agent/src/agent-loop.ts:332–384Cầu nối giữa bộ chạy được và transcript.
4extensions/types.ts:501–668ToolExposure, ToolNamespace, ToolLoadout, ToolDefinition.
5agent-session.ts:1498–1640, :1748–1861Loadout, bản vá prompt, phép chiếu, khôi phục từ transcript.
6system-prompt.ts:127–229Các mục prompt có tên và phép diff.
7anthropic-messages.ts:1202–1276, :1353–1399Tool cố định cộng placeholder, tool_addition/tool_removal.
8openai-responses-shared.ts:156–300additional_tools, tool search giả lập, thông báo gỡ tool, configuration_update.
9core/cache-warmer.ts, sdk.ts:333–423Toàn bộ vòng đời làm ấm cache và chỗ nó được nối vào Agent.
10core/virtual-models.ts, docs/virtual-models.md, jev-router.tsKiểu dữ liệu, tài liệu, một router thật.
11model-runtime.ts:659–832, ai/src/models.ts:158–247Chat, ảnh, classifier đi qua cùng bước giải credential.
12README.md:168–180, generate-coding-agent-install-lock.mjs, package-manager-cli.ts:48–244, nix/package.nixChuỗ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.
  • ToolExposure tá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.

Mục lục

Giao diện