Tài liệu học · Pi coding agent, Pi 1.0, Pi Durable, Chord
Pi, đọc từ mã nguồn
Pi hoạt động thế nào, được xây ra sao, vì sao lại thiết kế như vậy, và dữ liệu của nó trông ra sao, giải thích bằng tiếng Việt và chứng minh bằng chính code của Pi.
1Cách dùng tài liệu này#
Đây là tài liệu học về Pi, một coding agent chạy trong terminal, cùng các thư viện bên dưới nó: Pi 1.0, Pi Durable và Chord. Mục tiêu không phải hướng dẫn sử dụng, mà là giúp bạn hiểu Pi từ bên trong: nó hoạt động thế nào, được triển khai ra sao, vì sao được thiết kế như vậy, và dữ liệu của nó có hình dạng gì. Mỗi khẳng định kỹ thuật đều trỏ tới code thật, kèm đường dẫn file và số dòng, để bạn mở đúng chỗ đó và tự kiểm chứng.
Bạn sẽ đọc thoải mái nhất nếu đã quen TypeScript (kiểu, async/await, generic) và biết sơ qua cách gọi API của mô hình ngôn ngữ: tin nhắn theo vai trò, gọi tool (tool calling), và stream. Những khái niệm khác được định nghĩa khi xuất hiện lần đầu.
Nguồn sự thật: một bản clone ghim cố định#
Toàn bộ code trong tài liệu được trích tự động từ bản clone của github.com/earendil-works/pi tại tag v1.4.0 (commit 4a080252f, phát hành 2026-10-21), bản phát hành mới nhất lúc viết tài liệu. Pi đổi rất nhanh (từ 1.0 lên 1.4 chỉ trong ba tuần), nên số dòng chỉ đúng với đúng commit này. Bản clone nằm ở thư mục pi/ cạnh tài liệu; nếu cần tạo lại:
git clone https://github.com/earendil-works/pi.git cd pi git checkout v1.4.0 # detached HEAD tại 4a080252f; mọi số dòng trong tài liệu khớp với trạng thái này
Thứ tự tin cậy khi các nguồn mâu thuẫn: code ở v1.4.0, rồi tới tài liệu trong repo (README và thư mục docs/ của từng package; trang pi.dev/docs là bản xuất bản của packages/coding-agent/docs), cuối cùng là blog của Earendil và các tác giả. Ý kiến hay số liệu lấy từ blog luôn được ghi rõ nguồn.
Cách đọc một khối code#
Mỗi khối code là một đoạn file thật, được build script đọc thẳng từ bản clone theo số dòng và kiểm tra rằng nó chứa đúng thứ được mô tả. Ví dụ, đây là toàn bộ file điểm vào của pi-agent-core:
export * from "./agent.ts";export * from "./agent-loop.ts";export * from "./proxy.ts";export { setDefaultStreamFn } from "./stream-fn.ts";export * from "./types.ts";- Đường dẫn như
pi/packages/agent/src/index.tslà vị trí trong bản clone. Bấm vào nó để mở đúng các dòng đó trên GitHub, tại đúng commit được ghim, nên liên kết không bao giờ trỏ lệch dù repo đã đổi. - Số dòng bên trái là số dòng thật trong file. Dòng được tô nền (như dòng 2 ở trên) là dòng văn bản đang nói tới.
- Chép vị trí chép chuỗi dạng
pi/packages/agent/src/index.ts:1; dán vào editor để mở đúng chỗ, chẳng hạncode -g pi/packages/agent/src/agent-loop.ts:172với VS Code hoặcvim +172 pi/packages/agent/src/agent-loop.ts. Chép code chép phần code, không kèm số dòng. - Dòng “⋯ lược bớt dòng a–b” nghĩa là đoạn giữa bị bỏ qua để giữ phần đầu và phần cuối của một hàm dài. Khối dài hơn 40 dòng được thu gọn, bấm Hiện đủ để xem hết.
- Thụt lề chung của đoạn trích được cắt bớt để dễ đọc; trong file gốc, code dùng tab.
Trong văn bản, những tham chiếu nhỏ màu xanh dạng packages/agent/src/agent-loop.ts:172 cũng là liên kết tới đúng dòng trên GitHub; rê chuột để thấy đường dẫn trong bản clone.
Quy ước trong văn bản#
- Khối chữ đơn cách không có đường dẫn file và được ghi “Ví dụ minh hoạ” là ví dụ viết tay (một dòng JSONL mẫu, một chuỗi sự kiện) để minh hoạ; nó khớp với kiểu dữ liệu thật nhưng không phải code của Pi. Chỉ những khối có đường dẫn
pi/…mới là code trích từ repo. - Thuật ngữ tiếng Anh chuẩn được giữ nguyên và giải nghĩa ở lần đầu: vòng lặp agent (agent loop), lượt (turn), lời gọi tool (tool call), cửa sổ ngữ cảnh (context window), compaction (nén ngữ cảnh)… Chương cuối gom tất cả thành bảng thuật ngữ.
- Tên định danh trong code luôn đặt trong
khung codevà giữ nguyên chữ hoa chữ thường.
Ba lộ trình đọc#
Tài liệu được sắp từ nền lên ngọn, khớp với đồ thị phụ thuộc giữa các package (chương Bản đồ monorepo). Bạn có thể đọc tuần tự, hoặc chọn một lộ trình:
Chương So sánh và lộ trình học ở cuối có thêm các bài tập lần theo code, để bạn kiểm tra mình đã hiểu tới đâu.
2Pi là gì: lịch sử và triết lý#
Pi tự mô tả bằng đúng một câu ở đầu README: “a minimal, extensible agent harness that you can make your own”, một harness cho agent, tối giản, mở rộng được, và để bạn biến thành của riêng mình (README.md:15). Để hiểu mã nguồn, trước hết cần hiểu ba từ trong câu đó, và vì sao những người viết ra Pi chọn chúng.
Harness là phần mềm bao quanh một mô hình ngôn ngữ để biến nó thành agent. Bài “What is a Harness?” của Earendil tóm lại bằng công thức Agent = Model + Harness và liệt kê bốn việc mà mọi harness đều làm: đưa ra chỉ dẫn (system prompt), mô tả và cung cấp công cụ (tools), điều khiển vòng lặp agent (agentic loop), và một lớp phiên dịch để chạy được với nhiều mô hình khác nhau (earendil.com, 2026-08-20). Cả tài liệu này thực chất là đi xem Pi làm bốn việc đó như thế nào, ở mức từng dòng code.
AGENTS.md của dự án. Xem chương Công cụ và system prompt.read, bash, edit, write; phần còn lại do extension thêm vào.Dòng thời gian#
Pi bắt đầu là dự án cá nhân của Mario Zechner (tác giả libGDX), trong một monorepo tên pi-mono, các package npm mang scope @mariozechner. Lịch sử git trong bản clone ghi lại khá rõ quá trình đó; các ngày dưới đây lấy từ commit và tag của repo (trừ các bài blog, có ghi nguồn).
| Ngày | Sự kiện | Bằng chứng |
|---|---|---|
| 2025-08-09 | Commit đầu tiên: monorepo với npm workspaces; các package đầu tiên là agent, tui, pods (triển khai vLLM). | commit a74c5da11 |
| 2025-08-17 | Ra đời pi-ai, “unified AI package with OpenAI, Anthropic, and Gemini support”. | commit f064ea0e1 |
| 2025-10-17 | Package coding-agent xuất hiện (“Agent package + coding agent WIP”). | commit ffc9be886 |
| 2025-11-30 | Mario viết “What I learned building an opinionated and minimal coding agent”, bài giải thích triết lý gốc. | mariozechner.at |
| 2026-01-31 | Armin Ronacher viết “Pi: The Minimal Agent Within OpenClaw”: Pi là lõi agent bên trong OpenClaw. | lucumr.pocoo.org |
| 2026-04-08 | Earendil Inc. (công ty public benefit của Armin Ronacher và Colin Daymond Hanna) mua lại dự án Pi; Mario gia nhập Earendil. | earendil.com |
| 2026-04-30 | Gỡ mom (bot Slack) và pods khỏi monorepo. | commit 0ed0d4343 |
| 2026-05-07 | Chuyển các package sang scope @earendil-works; repo thành earendil-works/pi. | commit 3e5ad67e0 |
| 2026-08-05 | Tách pi-telemetry thành package riêng. | commit 6b461b75b |
| 2026-08-28 | Đặt nền móng Chord (“add Chord runtime foundation”). | commit 28b49a6b3 |
| 2026-09-18 | Harness bền mang tên mã Pico được chuyển từ pi-agent-core sang package riêng: pi-durable. | commit 080160162 |
| 2026-09-29 | Bản 0.99.0: codemode và MCP vào lõi, theme hệ thống, đăng nhập ChatGPT, virtual models, mô hình phân loại. | CHANGELOG 0.99.0 |
| 2026-10-01 | Pi 1.0 phát hành cùng lúc với Pi Durable (thử nghiệm). Cùng ngày, harness thử nghiệm cũ bị gỡ khỏi pi-agent-core. | CHANGELOG 1.0.0, commit 7fd478a2e |
| 2026-10-05 | Thêm pi-env: môi trường thực thi từ xa cho Pi Durable, chạy qua SSH. | commit ba03e03f2 |
| 2026-10-12 | Gỡ bộ client/server/protocol thử nghiệm của coding agent, nhường chỗ cho Pi Durable. | commit 7472cda0d |
| 2026-10-21 | Bản 1.4.0, phiên bản mà mọi trích dẫn trong tài liệu này dựa vào. | tag v1.4.0 |
Tính tới v1.4.0, repo có 7.042 commit và 331 tag. Mario Zechner là tác giả của khoảng 3.955 commit, Armin Ronacher khoảng 910 (theo git shortlog -sn trên bản clone). Bài công bố Pi 1.0 nói Pi có “hàng trăm nghìn” người dùng mỗi tuần (earendil.com/posts/pi-1-0); con số này là tuyên bố của Earendil, không có trong repo.
Vì sao Pi ra đời#
Mario viết Pi vì các coding agent thương mại thời điểm đó đi ngược cách anh làm việc: tính năng ngày càng nhiều, system prompt thay đổi liên tục giữa các phiên bản, và người dùng gần như không thấy được mô hình thực sự nhận được gì. Bài blog năm 2025 liệt kê những thứ anh muốn có (mariozechner.at):
- Kiểm soát ngữ cảnh. “Context engineering is paramount”: cái gì vào cửa sổ ngữ cảnh phải do người dùng quyết định, không bị harness chèn thêm.
- Nhìn thấy mọi tương tác với mô hình, điều mà theo anh hầu như không harness nào cho phép.
- Một định dạng session được tài liệu hoá rõ ràng, để có thể xử lý lại bằng công cụ khác.
- Dễ dựng giao diện khác trên cùng một lõi agent (terminal, web, Slack…).
- Chạy tốt với mô hình tự host, nhất là phần gọi tool, thứ mà các harness dựa trên lớp trừu tượng chung thường làm kém.
Nguyên tắc chọn tính năng được anh nói thẳng: “if I don't need it, it won't be built”. Bốn năm tháng sau, khi Pi đã là sản phẩm của Earendil, nguyên tắc đó vẫn giữ nhưng được diễn đạt lại thành “độ phức tạp phải tự chứng minh giá trị của nó” (bài “Pi, Minimal and Performant”).
Bảy nguyên tắc thiết kế#
Đọc code Pi sẽ dễ hơn nhiều khi biết các nguyên tắc sau, vì phần lớn quyết định kỹ thuật là hệ quả trực tiếp của chúng.
1. Lõi tối giản, mặc định mạnh
Pi chỉ có bốn công cụ mặc định và một system prompt ngắn; bài blog năm 2025 cho biết system prompt cộng định nghĩa tool lúc đó dưới 1.000 token. README nói rõ Pi “bỏ qua” những tính năng mà các agent khác coi là hiển nhiên, như sub-agent và plan mode, và mời bạn tự xây hoặc cài package làm điều đó theo cách của bạn (README.md:19). Những tính năng bị “bỏ qua” này vẫn tồn tại, nhưng dưới dạng ví dụ extension (chương Extensions đi qua plan-mode và subagent).
2. Người dùng làm chủ ngữ cảnh, và giữ prefix ổn định
Pi tránh tự ý sửa những gì đã gửi cho mô hình. Lý do không chỉ là minh bạch mà còn là tiền: prompt cache của provider chỉ trúng khi phần đầu (prefix) của chuỗi token giống hệt lần trước. Bài “Prompt Caching In Agents” giải thích Pi ưu tiên bản ghi chỉ-nối-thêm (append-oriented), không cắt tỉa ngữ cảnh cũ một cách hung hăng, và coi compaction là một lần “reset cache” có chủ đích (earendil.com/posts/prompt-caching). Nhiều tính năng của bản 1.0, như thông điệp hệ thống giữa hội thoại và nạp tool trì hoãn, tồn tại chính để giữ prefix không đổi (chương Pi 1.0).
3. Dễ uốn nắn: agent tự viết phần mở rộng cho chính nó
Extension là module TypeScript chạy ngay trong tiến trình Pi; factory của nó đăng ký tool, lệnh, phím tắt, provider, trình xử lý sự kiện và cả giao diện terminal (how-pi-works.md:43). Armin Ronacher mô tả cách dùng điển hình: thay vì cài extension của người khác, bạn bảo Pi tự viết hoặc chỉnh một extension có sẵn, tải lại nóng (hot reload), thử, và lặp lại. Để điều đó khả thi, Pi đi kèm tài liệu và ví dụ mà chính agent đọc được. Bài “What is a Harness?” cho biết cộng đồng đã chia sẻ hơn 5.000 extension.
4. Session là dữ liệu của bạn, mang đi được
Session được lưu thành file JSONL; mỗi entry có ID và trỏ tới entry cha, nên cả session là một cái cây (how-pi-works.md:27). Một session có thể chứa tin nhắn của nhiều provider khác nhau, nên Pi tránh phụ thuộc vào các tính năng riêng của từng provider. Bài “The Session You Cannot Take With You” đẩy ý này thành nguyên tắc: nhật ký sự kiện cục bộ là bản gốc, và trạng thái mà provider niêm phong (lý luận mã hoá, compaction mờ đục) không được là nơi duy nhất chứa ý nghĩa; Pi không bao giờ chuyển compaction bị niêm phong của provider này sang provider khác (earendil.com/posts/session-portability).
5. Không có hệ thống phân quyền: an toàn đến từ ranh giới hệ điều hành
Đây là quyết định gây tranh cãi nhất. Pi không có lớp phân quyền nào cho file, tiến trình, mạng hay credential; nó chạy với quyền của người đã khởi động nó (README.md:88–90). Lập luận của Mario: một agent đọc được dữ liệu, chạy được code và truy cập được mạng thì luôn có thể bị prompt injection lợi dụng để rò rỉ dữ liệu; các biện pháp như hỏi xác nhận từng lệnh hay danh sách tên miền chỉ cho cảm giác an toàn. Vì vậy tài liệu bảo mật yêu cầu coi lệnh và code do mô hình sinh ra là không đáng tin, và tìm an toàn ở container, máy ảo hoặc sandbox (security.md:3). Cơ chế project trust chỉ quyết định có nạp tài nguyên của thư mục dự án hay không, chứ không giới hạn những gì tool làm được (security.md:31–33).
6. Trung lập với mô hình
Lớp phiên dịch pi-ai nói chuyện trực tiếp với API của từng provider thay vì đi qua một thư viện trừu tượng chung, và một session có thể đổi mô hình giữa chừng. Earendil coi đây là cách trả quyền lựa chọn cho người dùng: cùng một harness, bạn so được kết quả và chi phí của Anthropic, OpenAI hay mô hình mã nguồn mở trên cùng công việc. Ở v1.4.0, pi-ai định nghĩa sẵn 42 provider: hãng gốc, nền tảng cloud, gateway, gói thuê bao, và cả mô hình chạy trên máy bạn. Chương Provider vẽ bản đồ đó và mọi cách kết nối.
7. Tối giản để rẻ và nhanh
Bài “Pi, Minimal and Performant” dẫn một benchmark nội bộ của Databricks: với cùng mô hình và mức suy nghĩ, Pi có tỷ lệ hoàn thành cao nhất với chi phí thấp hơn đáng kể so với Claude Code và Codex, và gửi ít ngữ cảnh hơn khoảng 3 lần mỗi lượt (earendil.com). Đây là số liệu do Earendil trích dẫn, không phải kết quả bạn kiểm chứng được từ repo.
Những điều Pi đã đổi ý, và những điều giữ nguyên#
Một dự án tối giản vẫn thay đổi khi bằng chứng thay đổi. Hiểu những lần “đổi ý” này giúp bạn nhận ra phần nào trong code là lớp mới đắp lên lõi cũ.
| Chủ đề | Lập trường năm 2025 | Ở v1.4.0 |
|---|---|---|
| MCP | Không hỗ trợ: mô tả tool của MCP chiếm 7–9% cửa sổ ngữ cảnh ngay từ đầu (ví dụ Playwright MCP 21 tool, 13,7k token); dùng CLI kèm README thay thế. | MCP vào lõi từ 0.99.0, nhưng qua codemode: mô hình viết JavaScript gọi tool, và tool của MCP mặc định không nằm sẵn trong ngữ cảnh mà được tìm khi cần. Lý do được viết trong “You Said No MCP!”. Xem chương Codemode, MCP. |
| Giao diện terminal | Ghi nối vào scrollback gốc của terminal, như Claude Code và Codex. | Từ 1.0, mặc định là toàn màn hình; chế độ cũ vẫn còn với tuiMode: "regular". Xem chương pi-tui. |
| Sub-agent, plan mode, todo | Không có trong lõi. | Vẫn không có trong lõi coding agent; có trong ví dụ extension. Pi Durable thì xây subagent bằng tool và cuộc hội thoại do task sở hữu. |
| Chạy lâu, nhiều người cùng lái | Ngoài phạm vi. | Thành một package riêng, thử nghiệm: Pi Durable, thay vì bẻ cong coding agent. |
Pi 1.0 và Pi Durable#
Ngày 2026-10-01, Earendil phát hành Pi 1.0 như một harness “đã được tôi luyện” mà người và doanh nghiệp có thể dựa vào. Changelog của bản 1.0.0 khá ngắn, vì những thay đổi lớn đã vào từ 0.99.0 hai ngày trước đó:
## [1.0.0] - 2026-10-01### New Features- **Fullscreen by default** — The TUI now runs fullscreen. Set `tuiMode` to `"regular"` to keep the terminal's normal scrollback. See [Terminal and display](docs/settings.md#terminal-and-display).- **Leaner codemode** — About 40% fewer prompt tokens, and errors that tell the model how to recover. See [Codemode](docs/codemode.md).- **Image generation in codemode** — Scripts call `models.generateImages()` with the session's credentials. See [Generate images](docs/codemode.md#generate-images) and [Use image models](docs/models.md#use-image-models).- **Radius in `/login`** — Sign in with Radius and set up its MCP server in one step. See [Radius](docs/providers.md#radius).- **Anthropic copy code login** — Sign in when the browser runs on another machine. See [Authenticate interactively](docs/providers.md#authenticate-interactively).- **MCP OAuth hardening** — `oauth.authServerMetadataUrl`, RFC 9207 `iss` checks, credentials per server, and step-up sign-in that keeps granted scopes. See [Authenticate with OAuth](docs/mcp.md#authenticate-with-oauth).- **Header-only quiet startup** — `quietStartup: "header"` keeps the version and key hints and hides the rest. See [Terminal and display](docs/settings.md#terminal-and-display).Bài công bố liệt kê các điểm mới tính gộp cả 0.99: codemode với MCP, mô hình không phải LLM (bộ phân loại Jev) và mô hình sinh ảnh, virtual model, nạp tool trì hoãn, làm ấm cache cho Anthropic, thông điệp hệ thống giữa hội thoại, theme mới và chế độ toàn màn hình. Chương Pi 1.0 đi qua từng tính năng và cơ chế của nó trong code.
Cùng ngày, Pi Durable ra mắt như một package riêng, gắn nhãn thử nghiệm (durable/CHANGELOG.md:185). Bài công bố mô tả rõ vấn đề nó giải quyết: coding agent chạy trên một máy, trong một terminal, do một người điều khiển; nếu tiến trình chết, bạn xem chuyện gì đã xảy ra rồi bảo nó làm tiếp. Earendil muốn một harness chạy được ở bất cứ đâu, với được từ nhiều bề mặt, giữ được số cuộc hội thoại gần như không giới hạn, sống sót qua lỗi bên trong lẫn bên ngoài, và cho nhiều người cùng điều khiển một agent. Thay vì bẻ cong coding agent, họ viết một nền mới, nơi “mọi thứ harness chạy, từ gọi mô hình tới chạy một tool, đều là một task” được ghi lại trước khi hiển thị (earendil.com/posts/pi-durable). Pi Durable không thay thế coding agent; nó dùng chung pi-ai và triết lý, còn trạng thái thì dựa trên Chord. Các chương Chord, Pi Durable và So sánh đi sâu vào phần này.
Văn hoá kỹ thuật bạn sẽ thấy trong code#
File AGENTS.md ở gốc repo là quy tắc chung cho cả người lẫn agent đóng góp vào Pi. Vài quy tắc giải thích những đặc điểm bạn sẽ gặp khi đọc code:
- Cách giải thích thiết kế: vấn đề, ví dụ cụ thể hoặc một vết chạy ngắn, rồi mới tới giải pháp và lý do nó cần thiết (AGENTS.md:10–11). Tài liệu này cũng cố gắng giải thích theo đúng khuôn đó.
- Chỉ dùng cú pháp TypeScript “xoá được”: không
enum, khôngnamespace, không parameter property, để Node chạy thẳng mã nguồn bằng chế độ bỏ kiểu (type stripping) mà không cần biên dịch (AGENTS.md:24). Vì vậy bạn sẽ thấy các class khai báo field tường minh rồi gán trong constructor. - Nhiều agent cùng làm việc trong một thư mục: quy tắc git giả định nhiều phiên pi chạy song song trên cùng working tree, nên cấm
git add -A,git stashhayreset --hard(AGENTS.md:54). Pi được phát triển phần lớn bằng chính Pi; thư mục.pi/của repo chứa prompt template và skill mà maintainer dùng hằng ngày (chương Bản đồ monorepo). - Issue và PR của người mới bị tự động đóng, maintainer xem lại mỗi ngày và mở lại những cái đạt chất lượng; lý do được ghi thẳng là chống quá tải và chống “slop” do agent sinh ra (CONTRIBUTING.md:79–81).
- Coi thay đổi dependency là code cần review: ghim phiên bản chính xác, chặn bản phát hành npm trong ngày, khoá dependency bắc cầu cho bộ cài chính thức (README.md:168–180).
Tóm tắt#
- Pi là một harness: system prompt, tool, vòng lặp agent và lớp phiên dịch mô hình, được giữ tối giản có chủ đích.
- Dự án đi từ
pi-monocá nhân của Mario Zechner (2025) tớiearendil-works/pisau khi Earendil mua lại (2026-04), rồi 1.0 ngày 2026-10-01. - Các nguyên tắc cốt lõi: lõi nhỏ, người dùng kiểm soát ngữ cảnh và giữ prefix ổn định cho prompt cache, mở rộng bằng extension mà agent tự viết, session mang đi được, không có hệ thống phân quyền (an toàn đến từ ranh giới hệ điều hành), trung lập mô hình.
- Pi Durable là một nền riêng cho agent chạy lâu, bền trước sự cố, nhiều người cùng lái; nó dùng chung
pi-aivà xây trạng thái trên Chord.
3Bản đồ monorepo#
Repo earendil-works/pi là một monorepo dùng npm workspaces: mọi package nằm dưới packages/, cùng chung một package-lock.json và cùng số phiên bản (mọi package ở tag này đều là 1.4.0). Chương này vẽ bản đồ để bạn biết mỗi thư mục làm gì, package nào dựa trên package nào, và nên đọc theo thứ tự nào.
Mười một package#
Số dòng dưới đây đếm các file .ts/.tsx trong src/ của từng package ở v1.4.0 (không gồm test); số file test đếm trong test/. README gốc liệt kê bảy package công khai chính (README.md:72–84); bốn package còn lại là phụ trợ.
| Thư mục | Tên npm | Vai trò | Dòng src | File test | Chương |
|---|---|---|---|---|---|
ai | @earendil-works/pi-ai | API thống nhất cho mọi provider LLM, danh mục mô hình, OAuth | 27.854 | 184 | pi-ai, provider |
agent | @earendil-works/pi-agent-core | Vòng lặp agent: gọi mô hình, chạy tool, hàng đợi steer/follow-up | 2.539 | 7 | agent-core |
coding-agent | @earendil-works/pi-coding-agent | CLI pi: session, tool, extension, các chế độ chạy, SDK | 76.067 | 321 | từ AgentSession trở đi |
tui | @earendil-works/pi-tui | Thư viện giao diện terminal vẽ lại theo vi sai | 19.623 | 53 | pi-tui |
codemode | @earendil-works/pi-codemode | Sandbox JavaScript (QuickJS) mà khả năng duy nhất là gọi tool được tiêm vào | 3.037 | 8 | Codemode |
mcp | @earendil-works/pi-mcp | Client Model Context Protocol độc lập (stdio, HTTP, OAuth) | 3.277 | 6 | Codemode, MCP |
chord | @earendil-works/chord | Runtime có kiểu cho plugin, contract, state nhân bản và RPC qua JSON | 14.612 | 62 | Chord |
durable | @earendil-works/pi-durable | Harness bền: hội thoại, task và document lưu bằng commit nguyên tử | 27.259 | 121 | Pi Durable |
env | @earendil-works/pi-env | Môi trường thực thi từ xa cho Pi Durable: daemon nhỏ triển khai qua SSH | 2.346 | 8 | pi-env |
telemetry | @earendil-works/pi-telemetry | Hợp đồng telemetry trung lập nhà cung cấp: span, event, metric | 1.572 | 8 | telemetry |
evals | @earendil-works/pi-evals | Bộ đánh giá coding agent bằng vitest (không phát hành như thư viện) | 1.446 | 6 | evals |
Bài công bố Pi Durable nói harness này khoảng 15.000 dòng không tính test. Ở v1.4.0, thư mục src/ của nó đã khoảng 27.000 dòng, vì gồm cả các backend storage, bộ kiểm thử tuân thủ trong src/testing/, môi trường Node và code mode bền; phần lõi harness vẫn nhỏ hơn nhiều.
Đồ thị phụ thuộc: hai chồng tách biệt#
Điều quan trọng nhất cần nhận ra khi nhìn các file package.json: Pi không phải một khối, mà là hai chồng dùng chung tầng dưới.
Chồng coding agent (CLI pi) Chồng Pi Durable (thư viện, thử nghiệm)
pi-telemetry pi-telemetry
│ │ │
▼ ▼ ▼
pi-ai pi-ai chord pi-codemode
│ │ │ │
▼ └──────────┼────────────┘
pi-agent-core pi-tui pi-mcp pi-codemode ▼
│ │ │ │ pi-durable
└────────────┴─────────┴─────┬─────┘ │
▼ ▼
pi-coding-agent pi-env
pi-coding-agent không phụ thuộc pi-durable hay chord; pi-durable không phụ thuộc pi-agent-core. Hai chồng gặp nhau ở pi-ai, pi-codemode và pi-telemetry.Phụ thuộc nội bộ của coding agent nằm ngay đầu khối dependencies:
"dependencies": { "@earendil-works/pi-agent-core": "1.4.0", "@earendil-works/pi-ai": "1.4.0", "@earendil-works/pi-codemode": "1.4.0", "@earendil-works/pi-mcp": "1.4.0", "@earendil-works/pi-tui": "1.4.0",Còn Pi Durable dựa trên Chord, pi-ai, pi-codemode và pi-telemetry, không có pi-agent-core:
"dependencies": { "@earendil-works/chord": "1.4.0", "@earendil-works/pi-ai": "1.4.0", "@earendil-works/pi-codemode": "1.4.0", "@earendil-works/pi-telemetry": "1.4.0",Thứ tự build trong package.json gốc chính là một thứ tự topo của đồ thị trên: telemetry, chord, tui, codemode, mcp, ai, durable, env, agent, rồi coding-agent.
"workspaces": [ "packages/*", "apps/*", "packages/coding-agent/examples/extensions/with-deps", "packages/coding-agent/examples/extensions/custom-provider-anthropic", "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", "packages/coding-agent/examples/extensions/sandbox", "packages/coding-agent/examples/extensions/gondolin"],"scripts": { "clean": "npm run clean --workspaces", "build": "cd packages/telemetry && npm run build && cd ../chord && npm run build && cd ../tui && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",Phụ thuộc bên ngoài đáng chú ý#
Danh sách phụ thuộc bên ngoài cho biết mỗi package tự làm gì và giao gì cho thư viện khác:
- pi-ai dùng SDK chính thức của từng provider (
@anthropic-ai/sdk,openai,@google/genai, AWS Bedrock),partial-jsonđể đọc đối số tool khi nó còn đang stream, vàtypeboxcho schema của tool (ai/package.json). - pi-coding-agent dùng
jitiđể nạp extension viết bằng TypeScript lúc chạy,quickjs-wasicho codemode,@silvia-odwyer/photon-nodeđể xử lý ảnh,diffcho tooledit,highlight.jsđể tô màu code trong terminal,undicicho HTTP,proper-lockfileđể khoá file cấu hình khi nhiều tiến trình cùng ghi (coding-agent/package.json). - pi-tui chỉ cần
marked(parse Markdown) vàget-east-asian-width(độ rộng ký tự CJK); phần còn lại, kể cả thuật toán vẽ, là tự viết. - pi-durable dùng
@silvia-odwyer/photon(bản WebAssembly, chạy được cả trên Cloudflare),diffvàtypebox; còn lại đều là code của chính nó và Chord.
Các package thư viện công khai nhiều điểm vào con (subpath exports) để ứng dụng chỉ nạp phần mình cần. Ví dụ pi-ai có ./models, ./providers/*, ./api/*, ./oauth (ai/package.json:13), còn pi-durable tách riêng từng backend storage (./storage/sqlite/node, ./storage/sqlite/cloudflare, ./storage/jsonl/node, ./storage/indexeddb), các tool (./tools), code mode (./codemode) và bộ kiểm thử tuân thủ (./testing) (durable/package.json:8). Nhờ vậy một Durable Object trên Cloudflare không phải kéo theo code chỉ dành cho Node.
Ngoài packages/#
| Đường dẫn | Nội dung |
|---|---|
AGENTS.md, CONTRIBUTING.md | Quy tắc cho người và agent đóng góp; quy trình duyệt issue/PR. |
.pi/ | Tài nguyên Pi của chính repo: prompt template (/cl kiểm changelog, /is phân tích issue, /pr review PR, /wr “wrap it”: changelog, commit, push), skill (release, add-llm-provider, interactive-testing, github-work) và vài extension nhỏ. Đây là cách maintainer dùng Pi để phát triển Pi. |
scripts/ | Kiểm tra (pin dependency, import, install lock), phát hành, đóng gói, sinh danh mục mô hình. |
test.sh, pi-test.sh | Chạy test trong môi trường cô lập (HOME, tmp, cache riêng); chạy Pi từ mã nguồn ở bất kỳ thư mục nào. |
flake.nix, nix/ | Build Pi bằng Nix (thêm ở 1.0.1), kèm bản ghim danh mục mô hình để build offline. |
.github/ | CI và các workflow tự động đóng issue/PR của người mới. |
Một prompt template của maintainer trông như sau: frontmatter có mô tả và gợi ý đối số, thân là chỉ dẫn, và $ARGUMENTS được thay bằng những gì gõ sau lệnh. Cơ chế này được giải thích trong chương Extensions.
---description: Analyze GitHub issues (bugs or feature requests)argument-hint: "<issue>"---Analyze GitHub issue(s): $ARGUMENTSQuy ước kỹ thuật#
- TypeScript chạy thẳng, không biên dịch khi phát triển
- Từ 0.99.0 repo dùng TypeScript 7.0 (target ES2024) để kiểm kiểu và build, còn khi chạy từ mã nguồn thì dùng chế độ bỏ kiểu có sẵn của Node thay cho
tsx(CHANGELOG:463). Hệ quả là quy tắc chỉ dùng cú pháp xoá được (AGENTS.md:24), và các import tương đối ghi rõ đuôi.ts. - Định dạng và lint bằng Biome
- Thụt lề bằng tab, dòng tối đa 120 ký tự; file
models.generated.tsđược loại khỏi kiểm tra (biome.json:19–36). Trang này hiển thị tab rộng 2 ký tự. - Danh mục mô hình được sinh ra
- Không ai sửa tay
packages/ai/src/models.generated.ts; nó được sinh bằng script, còn dữ liệu mô hình chi tiết trongproviders/data/bị gitignore (AGENTS.md:28). Vì vậy bản clone của bạn không có dữ liệu đó; chương pi-ai giải thích nó được nạp thế nào. - Tài nguyên của coding agent đi qua
src/config.ts - Code không dùng
__dirnametrực tiếp mà gọi helper trongconfig.ts, vì cùng một mã phải chạy từ checkout, từ gói npm và từ binary độc lập (AGENTS.md:22). - Test cô lập
test.shtạo HOME, thư mục tạm và cache npm riêng, chỉ cho qua các biến môi trường cần thiết, và bỏ qua test cần gọi LLM thật khi không có khoá API (test.sh:4–13). Phần lớn test dùng vitest; riêngpi-tuidùngnode:test.
Điểm vào của lệnh pi ngắn đúng sáu dòng; mọi thứ còn lại bắt đầu từ main(), được giải thích trong chương Các chế độ chạy:
#!/usr/bin/env nodeimport { setupCli } from "./cli/setup.ts";import { main } from "./main.ts";setupCli();main(process.argv.slice(2));Những package đã biến mất#
Nếu đọc issue cũ, changelog hay blog, bạn sẽ gặp các package không còn trong packages/. Lịch sử git cho biết chúng đi đâu:
| Package | Tồn tại | Số phận |
|---|---|---|
pods | 2025-08-09 → 2026-04-30 | Công cụ triển khai vLLM; gỡ cùng mom. |
mom | 2025-11-26 → 2026-04-30 | Bot Slack dựa trên Pi; mảng chat nay ở repo earendil-works/pi-chat (README.md:86). |
web-ui | 2025-10-05 → 2026-05-20 | Thành phần giao diện web cho chat; gỡ khỏi workspace. |
browser-extension | 2025-10-01 → 2025-10-06 | Chuyển sang repo riêng (sitegeist). |
proxy, agent-old | 2025-10 → 2025-11/12 | Thử nghiệm sớm, đã gỡ. |
server, protocol, client | 2026-07 → 2026-10-12 | Bộ client/server từ xa thử nghiệm của coding agent (giao thức CBOR); gỡ sau khi Pi Durable ra đời. |
session-backends/sqlite-node | 2026-08-05 → 2026-10-01 | Backend SQLite cho harness thử nghiệm trong agent-core; gỡ cùng harness đó. |
Nên đọc theo thứ tự nào#
Tài liệu này đi từ dưới lên, khớp với đồ thị phụ thuộc. Nếu bạn có mục tiêu cụ thể, có thể nhảy thẳng:
4pi-ai: một API cho mọi mô hình#
Vai trò#
Gói @earendil-works/pi-ai (thư mục packages/ai) là tầng thấp nhất của Pi. Nó làm một việc: nhận một mô hình (model) cùng một hội thoại, gửi hội thoại đó tới nhà cung cấp, rồi trả về một luồng (stream) sự kiện đã chuẩn hoá, kết thúc bằng một AssistantMessage. Chính package.json tự mô tả gói là "Unified LLM API with automatic model discovery and provider configuration" (packages/ai/package.json:4).
pi-ai không chạy tool, không lưu session và không vẽ giao diện. Khi mô hình muốn gọi tool, pi-ai chỉ trả về một khối toolCall trong message; chạy tool rồi nối kết quả tool vào hội thoại là việc của người gọi. Trong monorepo có ba người gọi chính:
- pi-agent-core. Vòng lặp agent (agent loop) nhận một hàm kiểu
StreamFn, và chú thích của kiểu này ghi rằngModels.streamSimplekhớp đúng hình dạng đó (agent/src/types.ts). Vòng lặp được giải thích ở chương pi-agent-core. - Coding agent.
AgentSessiondựng một model runtime riêng (đăng ký provider, đọcmodels.json, chọn mô hình) trên nền runtimeModelscủa pi-ai. Phần đó thuộc chương AgentSession. - Pi Durable. Harness nhận một
Modelsdo host cung cấp (harness/types.ts) và gọiruntime.models.streamSimplekhi sinh câu trả lời (generation.ts:484). Xem chương task và generation của Durable.
Chú thích của StreamFn đặt ra một hợp đồng mà mọi tầng phía trên dựa vào, và pi-ai lặp lại nó trong kiểu StreamFunction của chính mình: hàm stream nhận một transcript đã chuẩn hoá, phải trả về AssistantMessageEventStream, và khi đã trả luồng về thì mọi lỗi request, mô hình hay runtime phải đi qua luồng dưới dạng một message có stopReason là "error" hoặc "aborted".
// Generic StreamFunction with typed options.//// Contract:// - Receives a normalized transcript: the system prompt and tools live in the// leading system message, never on the context itself.// - Must return an AssistantMessageEventStream.// - Direct streamSimple() calls may throw synchronously when request auth is// missing. Once a stream is returned, request/model/runtime failures should// be encoded in that stream.// - Error termination must produce an AssistantMessage with stopReason// "error" or "aborted" and errorMessage, emitted via the stream protocol.export type StreamFunction<TApi extends Api = Api, TOptions extends StreamOptions = StreamOptions> = ( model: Model<TApi>, context: TranscriptContext, options?: TOptions,) => AssistantMessageEventStream;pi-ai nói chuyện với nhà cung cấp qua SDK chính thức của từng hãng, ghim phiên bản chính xác (@anthropic-ai/sdk, openai, @google/genai, AWS SDK cho Bedrock), thay vì qua một thư viện trừu tượng trung gian. Ngoài các SDK chỉ có hai phụ thuộc đáng chú ý: partial-json để đọc tham số tool đang truyền dở và typebox cho lược đồ tool (package.json).
Mô hình tư duy: API, provider và mô hình#
Ba danh từ xuất hiện trong gần như mọi file của pi-ai, và hai trong số đó rất dễ nhầm với nhau:
- API (giao thức trên dây, wire protocol)
- Hình dạng request và luồng phản hồi mà một máy chủ hiểu, ví dụ
anthropic-messageshayopenai-completions. Mỗi API có đúng một module triển khai trongsrc/api/, xuất hai hàmstreamvàstreamSimple(ProviderStreams). - Provider (nhà cung cấp)
- Một endpoint cụ thể cùng cách xác thực và danh mục mô hình của nó:
anthropic,groq,openrouter. Nhiều provider dùng chung một API, và một provider có thể dùng nhiều API: GitHub Copilot chọn adapter theomodel.apitrong một bảng ba mục (github-copilot.ts). - Mô hình (model)
- Một bản ghi dữ liệu thuần:
id,api,provider,baseUrl, giá, cửa sổ ngữ cảnh (context window). Trườngapiquyết định module giao thức nào chạy; trườngproviderquyết định xác thực và endpoint.
export type KnownApi = | "openai-completions" | "mistral-conversations" | "openai-responses" | "azure-openai-responses" | "openai-codex-responses" | "anthropic-messages" | "bedrock-converse-stream" | "google-generative-ai" | "google-vertex" | "pi-messages";export type Api = KnownApi | (string & {});KnownApi | (string & {}) là một mẹo TypeScript: editor vẫn gợi ý các giá trị đã biết, nhưng một provider tự viết vẫn được dùng một chuỗi API mới mà không cần sửa kiểu.Request đi qua năm lớp. Hai lớp Models và Provider là runtime mới từ bản 0.80 (CHANGELOG 0.80.0); lớp adapter có từ đầu và là nơi chứa nhiều code nhất.
AgentSession của coding agent, harness của Pi Durable. Truyền một Context và các option.ModelscreateModels() trong models.ts: chuẩn hoá Context thành transcript, phân giải xác thực của provider, gộp header và identity, mở span telemetry, chuyển request tới provider sở hữu mô hình (model.provider).ProvidercreateProvider(): danh mục mô hình, cách xác thực, và bảng chọn adapter theo model.api.api/*.lazy.ts qua lazyApi(): chỉ import module adapter (và SDK của nó) ở request đầu tiên.api/anthropic-messages.ts, api/openai-responses.ts, ...: đổi message sang định dạng của hãng, gọi SDK, đọc luồng phản hồi, phát sự kiện đã chuẩn hoá.@anthropic-ai/sdk, openai, @google/genai, AWS SDK, hoặc fetch thuần (pi-messages).Bảng dưới đây lấy từ các lệnh import trong src/providers/*.ts ở v1.4.0. Cột cuối chỉ là ví dụ, không liệt kê hết.
| API | Module | Provider dùng nó (ví dụ) |
|---|---|---|
anthropic-messages | api/anthropic-messages.ts | anthropic, minimax, vercel-ai-gateway; theo từng mô hình trong github-copilot, openrouter, fireworks, opencode |
openai-responses | api/openai-responses.ts | openai, xai, meta; theo từng mô hình trong github-copilot, opencode |
openai-completions | api/openai-completions.ts | groq, cerebras, deepseek, together, nvidia, zai, kimi-coding, huggingface; theo từng mô hình trong openrouter, azure |
google-generative-ai, google-vertex | api/google-*.ts | google, google-vertex |
azure-openai-responses, openai-codex-responses | biến thể của Responses | azure, openai-codex |
mistral-conversations | api/mistral-conversations.ts: gọi thẳng endpoint Chat Completions gốc của Mistral bằng fetch, không qua SDK (mistral-conversations.ts:304–315) | mistral |
bedrock-converse-stream | api/bedrock-converse-stream.ts: AWS SDK (ConverseStream) | amazon-bedrock |
pi-messages | api/pi-messages.ts | radius: giao thức riêng của Pi, POST { model, context, options } và nhận lại chính các sự kiện của pi-ai (pi-messages.ts) |
Bài viết của Mario Zechner năm 2025 (What I learned building an opinionated and minimal coding agent) kể pi-ai nói chuyện với bốn API: OpenAI Completions, OpenAI Responses, Anthropic Messages và Google. Ở v1.4.0 danh sách chat đã lên mười, cộng thêm các API ảnh và classifier, nhưng ý tưởng không đổi: mỗi giao thức một adapter viết tay, và mọi adapter phát ra cùng một bộ sự kiện.
Các từ vựng còn lại của chương:
Context: đầu vào công khai, gồmsystemPrompt,messages,tools.TranscriptContext: đầu vào mà adapter nhận, chỉ cómessages; system prompt và tool nằm trong system message ở đầu.AssistantMessageEventStream: luồng sự kiện vừa là async iterable vừa córesult()trả về message cuối.streamvàstreamSimple: biến thể nhận option riêng của từng API, và biến thể nhận option thống nhất nhưreasoning.
Data model#
Gần như mọi kiểu dữ liệu nằm trong một file, src/types.ts. Một tính chất chạy xuyên suốt: mọi giá trị ở đây đều là JSON. Không có class, không có hàm gắn vào message hay mô hình. Vì thế một hội thoại lưu xuống file rồi đọc lại vẫn gửi tiếp được, kể cả cho một mô hình khác (README, Context Serialization).
Mô hình: BaseModel và Model
/** Fields shared by every catalog entry, regardless of what you can do with it. */export interface BaseModel<TApi extends string> { id: string; name: string; api: TApi; provider: ProviderId; baseUrl: string; input: ModelInputModality[]; /** Provider input limits and cache-safe preprocessing metadata. */ inputLimits?: ModelInputLimits; cost: ModelCost; headers?: Record<string, string>;}/** Chat model: usable with `stream()` and friends. */export interface Model<TApi extends Api> extends BaseModel<TApi> { /** * Optional: chat is the default model type, so models without `type` are chat * models. Narrow mixed model lists with `isModelType()` instead of comparing * `type` directly. */ type?: "chat"; reasoning: boolean; /** * Maps pi thinking levels to provider/model-specific values. * Missing keys use provider defaults. null marks a level as unsupported. */ thinkingLevelMap?: ThinkingLevelMap; /** Prompt cache lifetimes per retention tier. Unset when the provider's cache behavior is unknown. */ promptCache?: ModelPromptCache; contextWindow: number; maxTokens: number; /** Default sampling parameters for this model. See {@link StreamOptions.samplingParams}; per-request keys override these. */ samplingParams?: SamplingParams; /** Sampling parameter overrides selected by the effective pi thinking level. */ samplingParamsByThinkingLevel?: SamplingParamsByThinkingLevel;api,provider,baseUrl: ba trường định tuyến.Modelstìm provider theoprovider; provider tìm adapter theoapi; adapter gửi tớibaseUrl.input:["text"]hoặc["text", "image"]. Nếu mô hình không nhận ảnh, ảnh trong lịch sử không bị bỏ lặng lẽ như README nói (packages/ai/README.md:756) mà được thay bằng một dòng chữ giữ chỗ như(image omitted: model does not support images)(transform-messages.ts).cost: giá USD cho mỗi triệu token, có thể kèm các bậc giá theo độ dài prompt.reasoningvàthinkingLevelMap: mô hình có suy nghĩ hay không, và mỗi mức suy nghĩ (thinking level) của Pi được gửi đi thành giá trị gì.nullnghĩa là mức đó không được hỗ trợ.promptCache: tuổi thọ (giây) của prompt cache cho từng bậc lưu giữ; Pi dùng nó để quyết định có làm ấm cache hay không (chương Pi 1.0).contextWindow,maxTokens: cửa sổ ngữ cảnh và giới hạn token đầu ra.type: tuỳ chọn; mô hình không cótypelà mô hình chat. Mô hình ảnh và classifier dùng chungBaseModelnhưng bắt buộc cótype(packages/ai/src/types.ts:882–895).
Trường compat có kiểu phụ thuộc vào TApi: mô hình openai-completions nhận các cờ của Chat Completions, ba API họ Responses nhận cờ của Responses, anthropic-messages nhận cờ của Anthropic, Bedrock và Mistral có bộ cờ nhỏ riêng; Google, pi-messages và API tự đặt nhận kiểu never, tức không có compat.
/** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */ compat?: TApi extends "openai-completions" ? OpenAICompletionsCompat : TApi extends "openai-responses" | "azure-openai-responses" | "openai-codex-responses" ? OpenAIResponsesCompat : TApi extends "anthropic-messages" ? AnthropicMessagesCompat : TApi extends "bedrock-converse-stream" ? BedrockCompat : TApi extends "mistral-conversations" ? MistralConversationsCompat : never;}Mức suy nghĩ, bảng ánh xạ và giá được khai báo bằng TypeBox trong providers/model-schema.ts, để cùng một lược đồ vừa sinh ra kiểu TypeScript vừa kiểm tra được models.json của người dùng: coding agent ghép chúng vào lược đồ của file cấu hình và biên dịch thành validator (model-config.ts).
export const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;export const MODEL_THINKING_LEVELS = ["off", ...THINKING_LEVELS] as const;export const MODEL_INPUT_MODALITIES = ["text", "image"] as const;export const CACHE_RETENTIONS = ["none", "short", "long"] as const;export const MODEL_PROMPT_CACHE_RETENTIONS = ["short", "long"] as const;const ThinkingLevelMapValueSchema = Type.Union([Type.String(), Type.Null()]);export const ThinkingLevelMapSchema = Type.Partial(Type.Record(ModelThinkingLevelSchema, ThinkingLevelMapValueSchema), { description: "Provider value sent for each thinking level. null marks a level unsupported. Levels the model does not map use the provider's default value through high; xhigh and max are unsupported.",});const ModelCostRatesProperties = { input: Type.Number({ description: "Input cost in USD per million tokens." }), output: Type.Number({ description: "Output cost in USD per million tokens." }), cacheRead: Type.Number({ description: "Cache-read cost in USD per million tokens." }), cacheWrite: Type.Number({ description: "Cache-write cost in USD per million tokens." }),};export const ModelCostRatesSchema = Type.Object(ModelCostRatesProperties);export const ModelCostTierSchema = Type.Object({ inputTokensAbove: Type.Number({ description: "Use this tier when total request input exceeds this token count.", }), ...ModelCostRatesProperties,});export const ModelCostSchema = Type.Object({ ...ModelCostRatesProperties, tiers: Type.Optional( Type.Array(ModelCostTierSchema, { description: "Request-wide pricing tiers. The highest matching input threshold applies to the full request.", }), ),});Một mô hình tự định nghĩa trông như sau. Đây là ví dụ trong README cho Ollama: chỉ là dữ liệu, không có code nào đi kèm.
const ollamaModel: Model<'openai-completions'> = { id: 'llama-3.1-8b', name: 'Llama 3.1 8B (Ollama)', api: 'openai-completions', provider: 'ollama', baseUrl: 'http://localhost:11434/v1', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 32000};Context và TranscriptContext
/** * Request input accepted by the public stream entry points (`Models.stream()`, * `streamSimple()`, ...). `systemPrompt` and `tools` are shorthand for a leading * system message; `normalizeContext()` folds them into one before the request * reaches a provider. */export interface Context { systemPrompt?: string; messages: Message[]; tools?: Tool[];}declare const transcriptContextBrand: unique symbol;/** * Normalized request context passed to providers and API implementations. The * prompt and tool declarations are carried by the transcript's system messages. * Only `normalizeContext()` produces this type, so a raw `Context` cannot reach * provider code by accident. */export type TranscriptContext = { messages: Message[]; readonly [transcriptContextBrand]: true;};Context.systemPrompt và Context.tools chỉ là cách viết tắt. Trước khi tới provider, normalizeContext() gộp chúng thành một system message đứng đầu transcript, với timestamp: 0. TranscriptContext mang một trường kiểu unique symbol chỉ tồn tại ở mức kiểu, nên không thể vô tình truyền một Context thô vào code của adapter: chỉ normalizeContext() tạo ra được giá trị này.
export function createInitialSystemMessage( systemPrompt: string | undefined, tools: Tool[] | undefined,): SystemMessage | undefined { const hasSystemPrompt = systemPrompt !== undefined && systemPrompt.length > 0; const hasTools = tools !== undefined && tools.length > 0; if (!hasSystemPrompt && !hasTools) return undefined; return { role: "system", content: systemPrompt ?? "", ...(hasTools ? { toolsAdded: tools } : {}), timestamp: 0, };}/** * Fold `Context.systemPrompt` and `Context.tools` into a leading system message. * This is the only entry point that produces a {@link TranscriptContext}; every * provider-facing function expects the result. */export function normalizeContext(context: Context): TranscriptContext { const initialMessage = createInitialSystemMessage(context.systemPrompt, context.tools); const messages = initialMessage ? [initialMessage, ...context.messages] : context.messages; return { messages } as TranscriptContext;}Message: bốn vai
Message là hợp của bốn kiểu: SystemMessage | UserMessage | AssistantMessage | ToolResultMessage. System message là phần được thêm ở bản 0.86 (packages/ai/CHANGELOG.md:279) và là nền cho việc đổi prompt hoặc bộ tool giữa hội thoại mà không viết lại lịch sử.
/** * System instructions and tool declarations at one point in the transcript. * * The leading system message is the system prompt. Later system messages change it: * `content` adds instructions from that point on, `sections` replace or remove named * prompt sections, and `toolsAdded`/`toolsRemoved` change the tool set. Replaying * every system message in order yields the current prompt and tools. Providers that * accept system messages mid-conversation send each one in place; other providers * rebuild the leading system message from the replayed state. */export interface SystemMessage { role: "system"; /** Instruction text. On the leading message this is the base prompt; later, additional instructions. */ content: string | TextContent[]; /** * Named, ordered prompt sections rendered verbatim after `content`. The leading message * declares them; later messages replace sections by name, and `null` removes one. Keep * each section self-delimiting (a tag, a heading) so the model can relate an update to * the original. Avoid integer-like names; JSON objects reorder those. */ sections?: Record<string, string | null>; /** Complete definitions of tools that become available at this point. */ toolsAdded?: Tool[]; /** Tools that stop being available at this point. */ toolsRemoved?: ToolReference[]; timestamp: number; // Unix timestamp in milliseconds}export interface UserMessage { role: "user"; content: string | (TextContent | ImageContent)[]; timestamp: number; // Unix timestamp in milliseconds}System message đầu tiên là system prompt. Các system message sau thay đổi nó: content thêm chỉ dẫn, sections thay hoặc xoá các đoạn có tên, toolsAdded và toolsRemoved đổi bộ tool. Phát lại (replay) mọi system message theo thứ tự sẽ ra prompt và bộ tool hiện tại, đúng như getCurrentTools() và getCurrentSystemMessage() làm (transcript.ts). Mô hình nào không nhận system message giữa hội thoại thì nhận bản đã gộp từ collapseSystemMessages(); chi tiết thiết kế này thuộc chương Pi 1.0.
export interface AssistantMessage { role: "assistant"; content: (TextContent | ThinkingContent | ToolCall)[]; api: Api; provider: ProviderId; model: string; responseModel?: string; // Concrete model reported by the provider when different from the requested `model` responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one /** Exact provider-native effort level used for this response. Absent for legacy or unmanaged responses. */ providerThinkingLevel?: string; /** Pi thinking level the agent loop requested for this response. Absent outside the agent loop and for legacy responses. */ thinkingLevel?: ModelThinkingLevel; diagnostics?: AssistantMessageDiagnostic[]; // Redacted provider/runtime diagnostics for failures and recoveries. usage: Usage; stopReason: StopReason; deferred?: DeferredHandle; errorMessage?: string; rawStopReason?: string; /** * Provider indication of whether the model explicitly ended its turn. * Preserved for debugging and does not currently affect agent control flow. */ endTurn?: boolean; /** Unix timestamp in milliseconds when the request started. */ timestamp: number; /** * Milliseconds from `timestamp` until the response ended, measured with a monotonic clock. Set by * `AssistantMessageEventStream` on the final message of a response it saw start; absent for legacy messages and * for deferred results fetched later. */ durationMs?: number;}api,provider,modelghi lại ai đã sinh ra message. Khi đổi mô hình giữa chừng,transformMessages()dựa vào ba trường này để biết khối nào còn dùng lại được (xem phần chuyển giao).responseModelchỉ có khi máy chủ thực sự trả lời bằng mô hình khác mô hình được yêu cầu, ví dụ fallback phía máy chủ của Anthropic.providerThinkingLevellưu mức effort đúng như đã gửi cho nhà cung cấp, để các request sau phát lại được mà không làm hỏng prompt cache.usagevàstopReasonluôn có mặt, kể cả khi lỗi.errorMessagecó khistopReasonlàerrorhoặcaborted;rawStopReasongiữ lý do gốc của nhà cung cấp để gỡ lỗi.timestamplà lúc request bắt đầu;durationMsdo luồng đo bằng đồng hồ đơn điệu (monotonic clock).
export type ToolResultMessage<TDetails = JsonValue> = IsJsonCompatible<TDetails> extends true ? { role: "toolResult"; toolCallId: string; toolName: string; content: (TextContent | ImageContent)[]; // Supports text and images details?: JsonRepresentation<TDetails>; /** Usage from the tool execution itself, if available. Not part of main LLM context accounting. */ usage?: Usage; /** Calls this tool made to other tools. Kept for the session record; not sent to the model. */ nestedCalls?: NestedToolCalls; isError: boolean; /** Unix timestamp in milliseconds when the result was created. */ timestamp: number; /** Milliseconds the tool's execution took, measured with a monotonic clock. Absent for legacy results. */ durationMs?: number; } : never;export type Message = SystemMessage | UserMessage | AssistantMessage | ToolResultMessage;Kết quả tool tách làm hai phần, đúng như bài viết của Mario mô tả: content (chữ và ảnh) là thứ gửi cho mô hình, còn details là dữ liệu cho giao diện và session, không bao giờ được gửi đi; adapter Anthropic chỉ đọc msg.content khi dựng tool_result (anthropic-messages.ts). Kiểu điều kiện IsJsonCompatible<TDetails> biến ToolResultMessage thành never nếu details không phải JSON, nên lỗi lưu session bị bắt ngay lúc biên dịch.
Content block
export interface TextContent { type: "text"; text: string; textSignature?: string; // e.g., for OpenAI responses, message metadata (legacy id string or TextSignatureV1 JSON)}export interface ThinkingContent { type: "thinking"; thinking: string; thinkingSignature?: string; // Provider-specific opaque or serialized reasoning replay data /** When true, the thinking content was redacted by safety filters. The opaque * encrypted payload is stored in `thinkingSignature` so it can be passed back * to the API for multi-turn continuity. */ redacted?: boolean;}export interface ImageContent { type: "image"; data: string; // base64 encoded image data mimeType: string; // e.g., "image/jpeg", "image/png"}export interface ToolCall { type: "toolCall"; id: string; name: string; arguments: JsonObject; thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context /** OpenAI Responses namespace for calls to dynamically loaded or namespaced tools. */ namespace?: string; /** * Set when the model's complete arguments text was not a valid JSON object. `arguments` is then `{}`. * Describes the problem and includes the text the model sent; tool argument validation fails with it. */ argumentsError?: string;}TextContent.textSignature: siêu dữ liệu để phát lại. OpenAI Responses lưu ở đây id vàphasecủa message dưới dạng JSONTextSignatureV1(packages/ai/src/types.ts:416–420, openai-responses-shared.ts).ThinkingContent.thinkingSignature: dữ liệu mờ (opaque) để gửi lại suy nghĩ cho đúng nhà cung cấp đã sinh ra nó. Với Anthropic đó là chuỗi signature; với OpenAI Responses là nguyên reasoning item dạng JSON, kể cảencrypted_content; với Chat Completions là tên trường reasoning hoặc mảngreasoning_details.redacted: trueđánh dấu suy nghĩ bị bộ lọc an toàn che đi: chữ hiển thị là[Reasoning redacted], phần mã hoá nằm trong signature (anthropic-messages.ts).ImageContent: base64 cùngmimeType.ToolCall:id,namevàargumentslà mộtJsonObject.thoughtSignaturechỉ Google dùng;namespacechỉ OpenAI Responses dùng;argumentsErrorxuất hiện khi chuỗi tham số hoàn chỉnh không phải JSON object hợp lệ (thêm ở 1.3.0).
Usage, chi phí và StopReason
export interface Usage { input: number; output: number; cacheRead: number; cacheWrite: number; /** Subset of `cacheWrite` written with 1h retention. Only Anthropic reports this split. */ cacheWrite1h?: number; /** * Reasoning/thinking tokens, when the provider reports them. This is a subset of * `output`: `output` already includes these tokens. Set to a number (possibly 0) by * providers that expose a reasoning breakdown; left undefined by providers that don't. */ reasoning?: number; totalTokens: number; cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number; };}export type StopReason = "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred";Quy ước quan trọng: input không gồm token đọc hay ghi cache. OpenAI tính cả token cache vào input_tokens, nên adapter phải trừ ra (openai-responses-shared.ts); Anthropic báo riêng từng loại nên chỉ cần cộng lại thành totalTokens. reasoning là tập con của output. Chi phí luôn được tính lại từ giá trong catalog:
export function calculateCost(model: AnyModel, usage: Usage): Usage["cost"] { const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite; let rates: ModelCostRates = model.cost; let matchedThreshold = -1; for (const tier of model.cost.tiers ?? []) { if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) { rates = tier; matchedThreshold = tier.inputTokensAbove; } } // Anthropic charges 2x base input for 1h cache writes. const longWrite = usage.cacheWrite1h ?? 0; const shortWrite = usage.cacheWrite - longWrite; usage.cost.input = (rates.input / 1000000) * usage.input; usage.cost.output = (rates.output / 1000000) * usage.output; usage.cost.cacheRead = (rates.cacheRead / 1000000) * usage.cacheRead; usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1000000; usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; return usage.cost;}Bậc giá được chọn theo tổng input của cả request (gồm token cache), lấy ngưỡng cao nhất mà request vượt qua, rồi áp cho toàn bộ request. Ghi cache 1 giờ của Anthropic được tính bằng hai lần giá input. Hàm ghi thẳng vào usage.cost nên adapter gọi nó mỗi khi usage thay đổi.
StopReason có bảy giá trị. pending chỉ xuất hiện trên message đang dở; toolUse nghĩa là mô hình đang chờ kết quả tool; deferred nghĩa là nhà cung cấp trả về một DeferredHandle để lấy kết quả sau (packages/ai/src/types.ts:532–542), thứ Pi Durable dùng qua fetchDeferred và cancelDeferred. Mục Stop Reasons của README chưa liệt kê deferred (packages/ai/README.md:1101–1110).
Tool và lược đồ TypeBox
/** OpenAI grammar variants for constrained sampling. */export type GrammarFormat = "openai_lark" | "openai_regex";export type GrammarVariants = Partial<Record<GrammarFormat, string>>;/** * Optional provider-side constrained sampling configs for a tool. * * The `json_schema` value roughly maps to the concept of `strict` in APIs which is * implemented as json-schema constrained sampling by APIs. Grammar variants let * callers provide provider-specific encodings of the same intended language. */export type ConstrainedSamplingConfig = | { type: "json_schema"; strict: "prefer" | "require"; } | { type: "grammar"; variants: GrammarVariants; };export interface Tool<TParameters extends TSchema = TSchema> { name: string; description: string; parameters: TParameters; constrainedSampling?: false | ConstrainedSamplingConfig;}export interface ToolReference { name: string;}parameters là một lược đồ TypeBox, mà lúc chạy chính là một object JSON Schema thuần, nên một tool cũng serialize được như mọi thứ khác. Gói gốc tái xuất Type từ TypeBox (index.ts). constrainedSampling cho phép yêu cầu nhà cung cấp ép đầu ra theo lược đồ (strict: "prefer" thì lùi về gọi tool thường khi không được hỗ trợ, "require" thì báo lỗi) hoặc theo văn phạm Lark/regex của OpenAI (README).
Tham số mà mô hình gửi về chưa được kiểm tra. Người gọi dùng validateToolCall(), hàm tìm tool theo tên rồi gọi validateToolArguments():
export function validateToolArguments(tool: Tool, toolCall: ToolCall): any { if (toolCall.argumentsError) { throw new Error(`Invalid arguments for tool "${toolCall.name}": ${toolCall.argumentsError}`); } const args = structuredClone(toolCall.arguments); normalizeOptionalNulls(args, tool.parameters as JsonSchemaObject); Value.Convert(tool.parameters, args); const validator = getValidator(tool.parameters); if (!Object.getOwnPropertySymbols(tool.parameters).includes(TYPEBOX_KIND)) { const coerced = coerceWithJsonSchema(args, tool.parameters as JsonSchemaObject); if (coerced !== args) { if (typeof args === "object" && args !== null && typeof coerced === "object" && coerced !== null) { for (const key of Object.keys(args)) { delete args[key]; } Object.assign(args, coerced); } else { return validator.Check(coerced) ? coerced : args; } } } if (validator.Check(args)) { return args; } const errors = validator .Errors(args) .map((error) => ` - ${formatValidationPath(error)}: ${error.message}`) .join("\n") || "Unknown validation error"; const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`; throw new Error(errorMessage);}Thứ tự đáng chú ý: tham số hỏng JSON (argumentsError) bị từ chối trước tiên; tham số được sao chép rồi ép kiểu bằng Value.Convert của TypeBox (ví dụ chuỗi "5" thành số khi lược đồ đòi số); validator đã biên dịch được cache theo object lược đồ trong một WeakMap. Thông báo lỗi liệt kê đường dẫn từng trường sai kèm tham số đã nhận, vì người gọi thường trả nó lại cho mô hình dưới dạng kết quả tool có isError: true để mô hình tự sửa (README).
Giao thức luồng sự kiện#
Mỗi nhà cung cấp truyền phản hồi theo một kiểu. Anthropic gửi các sự kiện SSE content_block_start/delta/stop đánh số theo chỉ số khối của Anthropic. OpenAI Responses gửi response.output_item.added/done theo output_index. Chat Completions nhét mọi thứ vào choices[0].delta, tham số tool đi theo tool_calls[i].index. Google trả về cả lời gọi hàm trong một lần. Trong khi đó bên tiêu thụ (vòng lặp agent, giao diện terminal) chỉ muốn một bộ từ vựng: chữ đang chạy tới, suy nghĩ đang chạy tới, một lời gọi tool đã xong, và câu trả lời đã xong. pi-ai định nghĩa bộ từ vựng đó một lần:
/** * Event protocol for AssistantMessageEventStream. * * Successful streams emit `start` before partial updates and terminate with * `done`. A stream may terminate directly with `error` when request setup fails * before generation starts; after `start`, failures also terminate with `error`. * Direct `streamSimple()` calls throw synchronously when request auth is missing. * Updates and `done` must never appear before `start`. * * `partial` is the shared live response-so-far helper, not an event-time * snapshot. Text and thinking blocks are empty when their `*_start` event is * emitted and grow only through their corresponding `*_delta` events until the * authoritative `*_end`. Redacted thinking may be complete at start and emit no * deltas. Tool-call arguments at `toolcall_start` are provider-specific; * `toolcall_delta` carries subsequent JSON updates. */export type AssistantMessageEvent = | { type: "start"; partial: AssistantMessage } | { type: "text_start"; contentIndex: number; partial: AssistantMessage } | { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage } | { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage } | { type: "thinking_start"; contentIndex: number; partial: AssistantMessage } | { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage } | { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage } | { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage } | { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage } | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage } | { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse" | "deferred">; message: AssistantMessage; } | { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };Các quy tắc trong chú thích trên (và trong README, Complete Event Reference) là hợp đồng mà mọi adapter phải giữ:
- Mở đầuLuồng thành công luôn bắt đầu bằng
start. Các adapter chỉ phátstartsau khi máy chủ đã chấp nhận request và luồng phản hồi đã mở, nên lỗi dựng request (thiếu khoá, sai URL, HTTP 400) có thể kết thúc luồng bằng một sự kiệnerrorduy nhất, không cóstart. - Các khốiMỗi khối có bộ ba
*_start,*_delta,*_end. Khối chữ và khối suy nghĩ rỗng lúc*_startvà chỉ lớn lên qua*_delta; giá trị chính thức làcontentcủa*_end. Khối suy nghĩ bị che có thể đầy đủ ngay lúc bắt đầu và không có delta. - Xen kẽSự kiện của các khối khác nhau có thể xen nhau. Người dùng phải ghép theo
contentIndex, không được giả định một khối chạy liền mạch. - Kết thúcĐúng một sự kiện cuối:
donevớireasonlàstop,length,toolUsehoặcdeferred, hoặcerrorvớireasonlàerrorhoặcaborted. Message trong sự kiện cuối là kết quả củaresult().
Hàng đợi bên dưới: EventStream
Luồng là một hàng đợi nhỏ viết tay, không dùng thư viện stream nào. Bên sản xuất (adapter) gọi push() đồng bộ; bên tiêu thụ dùng for await.
push(event: T): void { if (this.done) return; if (this.isComplete(event)) { this.done = true; this.resolveFinalResult(this.extractResult(event)); } // Deliver to waiting consumer or queue it const waiter = this.waiting.dequeue(); if (waiter) { waiter({ value: event, done: false }); } else { this.queue.enqueue(event); }}async *[Symbol.asyncIterator](): AsyncIterator<T> { while (true) { if (this.queue.length > 0) { yield this.queue.dequeue()!; } else if (this.done) { return; } else { const result = await new Promise<IteratorResult<T>>((resolve) => this.waiting.enqueue(resolve)); if (result.done) return; yield result.value; } }}result(): Promise<R> { return this.finalResultPromise;}push()không bao giờ chờ bên tiêu thụ. Nếu có người đang chờ thì giao thẳng, không thì xếp vào hàng. Không có áp lực ngược (backpressure): adapter chạy hết tốc độ mạng, sự kiện dồn trong bộ nhớ nếu không ai đọc.- Sự kiện kết thúc (
donehoặcerror) bật cờthis.donevà resolve promise củaresult()ngay lúc được push. Vì vậyawait s.result()chạy được dù không ai lặp qua luồng: đó chính là cáchcomplete()hoạt động. - Sau khi đã kết thúc, mọi
push()bị bỏ qua, nên adapter không thể vô tình phát hai sự kiện cuối. FifoQueuedùng hai mảng (đẩy vào một mảng, lấy ra từ mảng kia, đảo khi mảng lấy ra rỗng) đểdequeuecó chi phí khấu hao O(1) thay vìArray.shift()O(n) (event-stream.ts).
Lớp con AssistantMessageEventStream chỉ thêm hai điều: định nghĩa đâu là sự kiện cuối (done hoặc error), và gắn durationMs cho message cuối, đo từ lúc tạo luồng bằng performance.now() (event-stream.ts). Điều kiện message.timestamp < this.#startedAt giúp một luồng chỉ chuyển tiếp một phản hồi đã bắt đầu ở nơi khác (ví dụ kết quả deferred lấy về sau) không ghi đè thời lượng. Từ bản 1.1.0 mọi hàm stream bắt buộc trả về đúng lớp này (packages/ai/CHANGELOG.md:88).
Trả luồng ngay, làm việc chậm phía sau: lazyStream()
Phân giải xác thực có thể cần làm mới token OAuth; module adapter có thể chưa được nạp. Cả hai đều bất đồng bộ, nhưng stream() phải trả về luồng đồng bộ. lazyStream() giải quyết bằng cách tạo luồng ngoài ngay lập tức, chạy phần chuẩn bị phía sau, rồi chuyển tiếp từng sự kiện của luồng trong ra ngoài:
export function lazyStream( model: Model<Api>, setup: () => Promise<AsyncIterable<AssistantMessageEvent>>, observer?: LazyStreamObserver,): AssistantMessageEventStream { const startedAt = Date.now(); const outer = new AssistantMessageEventStream(); let observedResult = false; const observeResult = (result: AssistantMessage | undefined): void => { if (observedResult) return; observedResult = true; observer?.onResult?.(result); }; setup() .then((inner) => forwardStream(outer, inner, observer, observeResult)) .catch((error) => { const message = createSetupErrorMessage(model, error, startedAt); const event: AssistantMessageEvent = { type: "error", reason: "error", error: message }; outer.push(event); observer?.onEvent?.(event); outer.end(message); observeResult(message); }); return outer;}Lỗi trong setup() (provider không có, chưa cấu hình xác thực, nạp module thất bại) biến thành một message lỗi có timestamp là lúc request bắt đầu, phát dưới dạng sự kiện error. Đây là lý do Models.stream() không bao giờ ném lỗi. lazyApi() dùng cùng cơ chế để bọc một import() động: module adapter, và cùng với nó là SDK của hãng, chỉ được nạp ở request đầu tiên (lazy.ts, anthropic-messages.lazy.ts).
stream, complete và hai biến thể "simple"
stream<TApi extends Api>( model: Model<TApi>, context: Context, options?: ModelsApiStreamOptions<TApi>,): AssistantMessageEventStream { const transcript = normalizeContext(context); return lazyStream( model, async () => { const provider = this.requireChatProvider(model); const { requestModel, requestOptions } = await this.applyAuth( model, options as ModelsApiStreamOptions<Api> | undefined, ); return provider.stream(requestModel, transcript, requestOptions as ApiStreamOptions<TApi>); }, observeModelRequest(model, options, false), );}async complete<TApi extends Api>( model: Model<TApi>, context: Context, options?: ModelsApiStreamOptions<TApi>,): Promise<AssistantMessage> { return this.stream(model, context, options).result();}complete() chỉ là stream(...).result(). Khác biệt thật nằm giữa stream và streamSimple:
stream(model, context, options)nhận option đầy đủ của đúng API đó, ví dụthinkingEnabled,thinkingBudgetTokens,effortcho Anthropic, hayreasoningEffort,reasoningSummary,serviceTiercho OpenAI Responses. Kiểu option được suy ra từmodel.apiquaApiOptionsMap(packages/ai/src/types.ts:290–309); với mô hình tra cứu động, dùnghasApi()để thu hẹp kiểu.streamSimple(model, context, options)nhận một bộ option chung cho mọi API, trong đó quan trọng nhất làreasoning, một mức suy nghĩ của Pi. Mỗi adapter tự dịch mức này sang tham số của hãng. Đây là biến thể vòng lặp agent và Pi Durable dùng.
// Unified options with reasoning passed to streamSimple() and completeSimple()export interface SimpleStreamOptions extends StreamOptions { /** Provider-neutral tool selection for simple requests. When omitted, adapters use provider-specific behavior. */ toolChoice?: ToolChoice; reasoning?: ThinkingLevel; /** Ask a capable provider to return a durable handle and continue the request asynchronously. */ deferred?: boolean | { window?: "15m" | "1h" | "24h" }; /** Custom token budgets for thinking levels (token-based providers only) */ thinkingBudgets?: ThinkingBudgets;}Trước khi dịch, mức được yêu cầu bị kẹp vào những mức mô hình thật sự hỗ trợ. Mô hình không suy nghĩ chỉ có off; xhigh và max chỉ có khi thinkingLevelMap khai báo chúng; giá trị null loại một mức. Nếu mức được yêu cầu không có, clampThinkingLevel() tìm lên trước rồi mới tìm xuống:
const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];export function getSupportedThinkingLevels<TApi extends Api>(model: Model<TApi>): ModelThinkingLevel[] { if (!model.reasoning) return ["off"]; return EXTENDED_THINKING_LEVELS.filter((level) => { const mapped = model.thinkingLevelMap?.[level]; if (mapped === null) return false; if (level === "xhigh" || level === "max") return mapped !== undefined; return true; });}export function clampThinkingLevel<TApi extends Api>( model: Model<TApi>, level: ModelThinkingLevel,): ModelThinkingLevel { const availableLevels = getSupportedThinkingLevels(model); if (availableLevels.includes(level)) return level; const requestedIndex = EXTENDED_THINKING_LEVELS.indexOf(level); if (requestedIndex === -1) return availableLevels[0] ?? "off"; for (let i = requestedIndex; i < EXTENDED_THINKING_LEVELS.length; i++) { const candidate = EXTENDED_THINKING_LEVELS[i]; if (availableLevels.includes(candidate)) return candidate; } for (let i = requestedIndex - 1; i >= 0; i--) { const candidate = EXTENDED_THINKING_LEVELS[i]; if (availableLevels.includes(candidate)) return candidate; } return availableLevels[0] ?? "off";}Ví dụ: yêu cầu minimal với một mô hình đánh dấu minimal: null và low: null sẽ thành medium, không phải off. Ngoài ra buildBaseOptions() chép các option chung và kẹp maxTokens sao cho ước lượng của context cộng 4.096 token an toàn không vượt cửa sổ ngữ cảnh (simple-options.ts); ước lượng dùng 3,5 ký tự mỗi token (estimate.ts).
Các option chung
Mọi request, simple hay không, nhận một bộ option chung khai báo trong ProviderRequestOptions và StreamOptions. Những option quan trọng:
| Option | Dùng để làm gì |
|---|---|
signal | Huỷ request. Adapter kiểm tra nó trong vòng đọc luồng; message cuối có stopReason: "aborted" và giữ phần nội dung đã nhận. |
apiKey, headers, env | Xác thực và cấu hình theo request. Giá trị tường minh thắng giá trị provider tự phân giải; env được dùng trước process.env. |
sessionId | Khoá phiên dùng cho prompt cache và định tuyến (xem Prompt caching). |
cacheRetention | "none", "short" (mặc định) hoặc "long"; biến môi trường PI_CACHE_RETENTION=long đổi mặc định. |
onPayload, onResponse, onProviderStreamEvent | Xem hoặc thay payload trước khi gửi, xem status và header phản hồi, xem từng sự kiện thô của hãng trước khi chuẩn hoá. |
timeoutMs, maxRetries, maxRetryDelayMs | Timeout HTTP và chính sách thử lại phía client (xem phần lỗi). |
identity, telemetry, metadata | User-Agent và header ghi nhận ứng dụng; span cha cho telemetry; metadata mà hãng hiểu (Anthropic dùng user_id). |
Runtime Models và Provider#
Từ bản 0.80, cách dùng pi-ai là dựng một tập provider rồi gửi request qua nó. Trước đó có một API toàn cục (stream(model, ...) tra một registry theo model.api và tự lấy khoá từ biến môi trường); API đó vẫn còn, nhưng chuyển sang @earendil-works/pi-ai/compat và sẽ bị xoá (compat.ts).
import { createModels } from '@earendil-works/pi-ai/models';import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';const models = createModels();models.setProvider(openaiProvider());createModels() và đường đi của một request
createModels() nhận bốn thứ có thể thay thế: kho credential (CredentialStore, mặc định trong bộ nhớ), kho catalog động (ModelsStore), AuthContext (cách đọc biến môi trường và kiểm tra file) và identity mặc định (packages/ai/src/models.ts:376–382). Trả về MutableModels, tức Models cộng setProvider, deleteProvider, clearProviders. Mọi phép đọc mô hình là đồng bộ và trả về danh sách biết gần nhất; tải danh sách mới là một động từ bất đồng bộ riêng, refresh().
Khi gọi models.stream(), normalizeContext() chạy ngay, đồng bộ. Phần việc chậm nằm trong hàm setup của lazyStream(): requireChatProvider() kiểm tra mô hình là chat và tìm provider theo model.provider, applyAuth() phân giải xác thực, rồi provider.stream() trả về luồng bên trong. applyAuth() là chỗ các nguồn cấu hình gặp nhau:
this.requireProvider(model); const resolution = await this.getAuth(model, { apiKey: options?.apiKey, env: options?.env, signal: options?.signal, }); if (!resolution) { throw new ModelsError("auth", `Provider is not configured: ${model.provider}`); } const auth = resolution.auth; // Explicit request options win per-field; the Models-only transform runs last. const apiKey = options?.apiKey ?? auth.apiKey; const requestModel: TModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model; const sessionId = options && "sessionId" in options && typeof options.sessionId === "string" ? options.sessionId : undefined; let headers = mergeHeaders( mergeHeaders(getOpenCodeSessionHeaders(requestModel, sessionId), auth.headers), options?.headers, ); if (options?.transformHeaders) headers = await options.transformHeaders(headers ?? {}); const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined; const identity = mergeClientIdentity(this.identity, options?.identity); const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {}; const requestOptions = { ...providerOptions, apiKey, headers, env, ...(identity === undefined ? {} : { identity }), } as Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>; return { requestModel, requestOptions };}- Khoá tường minh trong option thắng khoá provider tự tìm. Xác thực có thể trả về
baseUrl(GitHub Copilot lấy URL theo tài khoản từ token OAuth), và khi đó mô hình được sao ra với URL mới. - Thứ tự gộp header: header phiên của OpenCode, rồi header xác thực (đã gồm
model.headersvìgetAuth(model)gộp sẵn), rồioptions.headers, rồitransformHeaders. Tên header so sánh không phân biệt hoa thường, và giá trịnullxoá một header mặc định (README). transformHeaderschỉ tồn tại ở tầngModels; nó bị gỡ khỏi option trước khi tới provider.
Xác thực được phân giải theo một nguyên tắc: credential đã lưu "sở hữu" provider. Biến môi trường chỉ được xét khi không có gì được lưu, và làm mới OAuth thất bại không lặng lẽ lùi về khoá trong biến môi trường:
if (overrides?.apiKey !== undefined && provider.auth.apiKey) { return resolveApiKey( requestAuthContext, provider.auth.apiKey, provider.id, { type: "api_key", key: overrides.apiKey, env: overrides.env, }, signal, );}const stored = await readCredential(credentials, provider.id, signal);if (stored) { if (stored.type === "oauth" && provider.auth.oauth) { return resolveStoredOAuth( credentials, provider.id, provider.auth.oauth, stored, signal, overrides?.minOAuthValidityMs, ); } if (stored.type === "api_key" && provider.auth.apiKey) { const credential = overrides?.env ? { ...stored, env: { ...stored.env, ...overrides.env } } : stored; return resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, credential, signal); } return undefined;}// Ambient (env vars, AWS profiles, ADC files).return provider.auth.apiKey ? resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, undefined, signal) : undefined;Provider và createProvider()
Interface Provider (packages/ai/src/models.ts:158–247) gom những gì thuộc về một nhà cung cấp: id, name, auth (bắt buộc, kể cả với máy chủ cục bộ không cần khoá), getModels() đồng bộ và không được ném lỗi, refreshModels() tuỳ chọn cho danh sách động, filterModels() để ẩn mô hình theo credential, và các thao tác stream, streamSimple, fetchDeferred, cancelDeferred, generateImages, classify. Hầu như không ai viết tay interface này: cả provider có sẵn lẫn provider tự định nghĩa trong models.json đều đi qua createProvider().
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> { const single = input.api && typeof (input.api as ProviderStreams).stream === "function" ? (input.api as ProviderStreams) : undefined; const byApi = single || !input.api ? undefined : (input.api as Partial<Record<string, ProviderStreams>>); const images = input.images as Partial<Record<string, ProviderImages>> | undefined; const classifiers = input.classifiers as Partial<Record<string, ProviderClassifier>> | undefined; const streams = single ? [single] : Object.values(byApi ?? {}).filter((entry) => entry !== undefined); const imageImplementations = Object.values(images ?? {}).filter((entry) => entry !== undefined); const classifierImplementations = Object.values(classifiers ?? {}).filter((entry) => entry !== undefined); if (streams.length === 0 && imageImplementations.length === 0 && classifierImplementations.length === 0) { throw new Error(`Provider ${input.id}: at least one of "api", "images", or "classifiers" is required.`); const currentModels = (): readonly ProviderModel<TApi>[] => { const merged = [...baselineModels]; for (const model of dynamicModels) { const index = merged.findIndex( (entry) => getModelType(entry) === getModelType(model) && entry.id === model.id, ); if (index >= 0) merged[index] = model; else merged.push(model); } return merged; }; const apiFor = (model: Model<Api>): ProviderStreams | undefined => single ?? byApi?.[model.api]; const dispatch = ( model: Model<Api>, run: (streams: ProviderStreams) => AssistantMessageEventStream, ): AssistantMessageEventStream => { const streams = apiFor(model); if (!streams) { return lazyStream(model, async () => { throw new ModelsError("stream", `Provider ${input.id} has no API implementation for "${model.api}"`); }); } return run(streams); };Ba điểm đáng nhớ. Một, api có thể là một ProviderStreams duy nhất hoặc một bảng tra theo model.api; mô hình có API không nằm trong bảng nhận một luồng lỗi chứ không ném ngoại lệ. Hai, danh sách mô hình là danh sách tĩnh cộng một lớp phủ (overlay) động, ghép theo cặp (loại, id), nên mô hình tải về thay thế bản tĩnh cùng id. Ba, factory của provider có sẵn chỉ là vài dòng: catalog, cách xác thực, và một lazy wrapper.
export function anthropicProvider(): Provider<"anthropic-messages"> { return createProvider({ id: "anthropic", name: "Anthropic", baseUrl: "https://api.anthropic.com", auth: { apiKey: anthropicApiKeyAuth(), oauth: lazyOAuth({ name: "Anthropic (Claude Pro/Max)", isSubscription: true, load: loadAnthropicOAuth, }), }, models: Object.values(ANTHROPIC_MODELS), api: anthropicMessagesApi(), });}refresh() chạy song song cho các provider động. Mỗi lần làm mới mang một số thế hệ (generation); kết quả chỉ được ghi vào ModelsStore và vào bộ nhớ nếu thế hệ đó vẫn là mới nhất, nên một lần làm mới cũ về muộn không đè lên kết quả mới (publishProviderModels). Bước đầu tiên của mỗi lần làm mới luôn là khôi phục catalog đã lưu với allowNetwork: false, trước cả khi xác thực, nên ứng dụng khởi động ngoại tuyến vẫn thấy danh sách cũ.
Subpath exports: chỉ nạp thứ cần dùng
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, "./models": { "types": "./dist/models.d.ts", "import": "./dist/models.js" }, "./compat": { "types": "./dist/compat.d.ts", "import": "./dist/compat.js" }, "./providers/*": { "types": "./dist/providers/*.d.ts", "import": "./dist/providers/*.js" }, "./api/*": { "types": "./dist/api/*.d.ts", "import": "./dist/api/*.js" }, "./utils/*": { "types": "./dist/utils/*.d.ts", "import": "./dist/utils/*.js" }, "./oauth": { "types": "./dist/oauth.d.ts", "import": "./dist/oauth.js" }, "./bedrock-provider": { "types": "./dist/bedrock-provider.d.ts", "import": "./dist/bedrock-provider.js" }, "./bun-oauth": { "types": "./dist/bun-oauth.d.ts", "import": "./dist/bun-oauth.js" }},| Entry point | Chứa gì |
|---|---|
. | Lõi, không có side effect: kiểu, runtime, tiện ích, faux provider. Không có catalog, factory hay SDK, nhưng có nạp TypeBox (packages/ai/src/index.ts:4–8). |
./models | Chỉ runtime (createModels, createProvider, helper mô hình), không TypeBox, không catalog, không SDK; thêm ở 0.99.2 (packages/ai/CHANGELOG.md:180). |
./providers/<id> | Factory của một provider, kéo theo đúng catalog của nó và lazy wrapper; ./providers/all nạp tất cả. |
./api/<api-id>, ./api/<api-id>.lazy | Adapter (nạp SDK ngay) và lazy wrapper của nó. Gọi thẳng adapter thì tự lo khoá và tự gọi normalizeContext(). |
./utils/*, ./oauth, ./bedrock-provider, ./bun-oauth | Tiện ích lẻ, kiểu OAuth cho extension, và hai entry cho bản đóng gói đơn file. |
./compat | API toàn cục cũ; là tập cha của gốc nên đổi đường import là đủ để chuyển (packages/ai/README.md:1877–1889). |
Ba file được khai báo có side effect trong sideEffects (packages/ai/package.json:8–12) vì chúng đăng ký vào registry toàn cục lúc import: compat.js đăng ký mười API có sẵn (packages/ai/src/compat.ts:200–215), images.js và register-builtins.js đăng ký API ảnh openrouter-images. Ở đường compat, khoá API còn được chèn từ biến môi trường bằng getEnvApiKey() (packages/ai/src/compat.ts:224–232), thứ mà runtime mới thay bằng xác thực của từng provider.
Pi Durable là ví dụ rõ nhất của cách dùng mới: harness không tự tạo provider nào, nó nhận một Models (hoặc một hàm trả về Models) từ host. Worker thử nghiệm trên Cloudflare dựng runtime với đúng một faux provider (worker.ts), và phần sinh câu trả lời dùng fetchDeferred, cancelDeferred khi nhà cung cấp trả về handle deferred (generation.ts:257).
Mô hình ảnh và mô hình classifier
Từ 0.99, mô hình ảnh và classifier sống chung trong Models và Provider với mô hình chat, phân biệt bằng trường type (packages/ai/CHANGELOG.md:199). Chúng không đi qua luồng sự kiện: generateImages() và classify() là thao tác một lần, chờ phản hồi rồi trả kết quả, và không bao giờ reject. Toàn bộ thân hàm nằm trong một try, nên mọi lỗi, kể cả provider không tồn tại hay chưa cấu hình xác thực, thành một kết quả có stopReason: "error" (models.ts). Đây là cùng tinh thần "lỗi là dữ liệu" của luồng chat, áp cho một thao tác không có luồng.
Classifier nhận một trạng thái JSON có cấu trúc (và tuỳ chọn ảnh) cùng một tập câu hỏi có kiểu, rồi trả về xác suất cho từng đáp án. Đây là loại "mô hình không phải LLM" mà bài Pi 1.0 nhắc tới, ví dụ Jev của TypeSafe.
export interface ClassifierChoiceQuestion { type: "choice"; instructions: string; criteria: Record<string, string>;}export interface ClassifierScoreQuestion { type: "score"; instructions: string; criteria: string[];}export interface ClassifierBoolQuestion { type: "bool"; instructions: string; criteria: { true: string; false: string };}export type ClassifierQuestion = ClassifierChoiceQuestion | ClassifierScoreQuestion | ClassifierBoolQuestion;export interface ClassifierContext { state: JsonObject; /** * Images judged together with `state`. Only models whose `input` includes `"image"` accept them; * other models return an error result. */ images?: ImageContent[]; questions: Record<string, ClassifierQuestion>;}Ở v1.4.0, sinh ảnh chỉ có qua OpenRouter (packages/ai/README.md:889); classifier có ở typesafe, openrouter, cloudflare-workers-ai, vercel-ai-gateway, opencode và openai (packages/ai/README.md:891–902).
Telemetry
Request qua Models.stream() và streamSimple() được bọc bởi một observer telemetry (dòng 917 trong excerpt Models.stream() ở trên). Observer đi chung đường chuyển tiếp sự kiện của lazyStream(), không thêm một bên tiêu thụ nào: nó mở span pi.ai.request, ghi sự kiện pi.ai.first_chunk ở sự kiện đầu tiên và pi.ai.block mỗi khi một khối kết thúc, rồi khi có kết quả thì gắn usage, chi phí, lý do dừng, đặt trạng thái lỗi provider_error với tối đa 500 ký tự thông báo, và đếm metric thời lượng, token, chi phí (observeModelRequest). Request bị huỷ giữ trạng thái ok với stop: "aborted".
Observer chỉ ghi số ký tự và tên tool, không bao giờ ghi prompt, chữ sinh ra hay tham số (packages/ai/README.md:1146). Không truyền telemetry thì dùng span rỗng; gọi thẳng adapter thì không có telemetry. Gói pi-telemetry được giải thích ở chương pi-env và telemetry.
Adapter Anthropic Messages từ đầu đến cuối#
src/api/anthropic-messages.ts (1.690 dòng) là adapter điển hình: các adapter khác có cùng bộ khung, chỉ khác phần đổi message và phần đọc luồng. Phần này đi theo thứ tự một request chạy qua file.
Khung: trả luồng ngay, làm việc trong một hàm async
export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( model: Model<"anthropic-messages">, context: TranscriptContext, options?: AnthropicOptions,): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); const normalizedContext = resolveTranscript(context, getAnthropicCompat(model).supportsMidConvoSystemMessages); const currentTools = getCurrentTools(normalizedContext.messages); (async () => { const output: AssistantMessage = { role: "assistant", content: [], api: model.api as Api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "pending", timestamp: Date.now(), };Ba việc xảy ra trước cả khi có mạng. Luồng được tạo và sẽ được trả về ở cuối hàm; công việc thật chạy trong một hàm async tự gọi (IIFE), nên mọi lỗi bên trong rơi vào một catch duy nhất thay vì thoát ra người gọi. resolveTranscript() gộp các system message giữa hội thoại nếu mô hình không hỗ trợ chúng. output là message sẽ được lấp dần: content rỗng, usage bằng 0, stopReason: "pending". Chính object này là partial của mọi sự kiện.
Chọn client và xác thực
Có bốn cách dựng client SDK. Người gọi có thể truyền sẵn options.client (ví dụ AnthropicVertex). Với GitHub Copilot, khoá được gửi dưới dạng Bearer cùng các header động. Với token OAuth của gói Claude Pro/Max (nhận ra bằng chuỗi sk-ant-oat), client đóng vai Claude Code. Còn lại là khoá API, header xác thực tự đặt, hoặc liên kết danh tính workload (workload identity federation). Mọi client do adapter tự dựng đều là PiAnthropic, một lớp con tắt chuỗi tìm credential riêng của SDK để chỉ bộ phân giải của pi quyết định (anthropic-messages.ts).
// OAuth: Bearer auth, Claude Code identity headers instead of the client identity.if (apiKey && isOAuthToken(apiKey)) { const client = new PiAnthropic({ apiKey: null, authToken: apiKey, baseURL: model.baseUrl, dangerouslyAllowBrowser: true, fetch, defaultHeaders: mergeHeaders( { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", "user-agent": `claude-cli/${claudeCodeVersion}`, "x-app": "cli", }, model.headers, optionsHeaders, ), }); return { client, isOAuthToken: true };}Ở nhánh OAuth, client gửi user-agent: claude-cli/<phiên bản> thay cho identity của ứng dụng, và về sau buildParams() chèn câu "You are Claude Code, Anthropic's official CLI for Claude." vào đầu system prompt, thêm beta claude-code-20250219 và oauth-2025-04-20, đổi tên tool sang đúng cách viết hoa của Claude Code như Read, Bash (anthropic-messages.ts). Tên tool trả về được đổi ngược lại theo danh sách tool hiện tại.
const requestOptions = { ...(options?.signal ? { signal: options.signal } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), maxRetries: 0,};let disablePreferredStrictTools = false;let response: Response;for (;;) { options?.signal?.throwIfAborted(); const built = buildParams(model, normalizedContext, isOAuth, options, disablePreferredStrictTools); if (built.providerThinkingLevel) output.providerThinkingLevel = built.providerThinkingLevel; let params = built.params; const nextParams = await options?.onPayload?.(params, model); if (nextParams !== undefined) { params = { ...(nextParams as MessageCreateParamsStreaming), stream: true }; } try { response = await retryProviderRequest( () => client.beta.messages.create(params, requestOptions).asResponse(), { maxRetries: options?.maxRetries, maxRetryDelayMs: options?.maxRetryDelayMs, signal: options?.signal, }, ); break;SDK được gọi với maxRetries: 0; việc thử lại do retryProviderRequest() của pi làm, vì thời gian chờ thử lại của SDK không dừng được bằng AbortSignal (provider-retry.ts). .asResponse() lấy Response thô để adapter tự đọc SSE. Vòng for (;;) chỉ lặp lại trong một trường hợp: Anthropic từ chối bộ tool strict (văn phạm quá lớn, quá nhiều tool strict) thì request được gửi lại một lần với các tool strict: "prefer" chuyển về không strict, và message nhận một diagnostic ghi lại việc này (anthropic-messages.ts).
Dựng request: buildParams()
const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env);const compat = getAnthropicCompat(model);const initialSystemMessage = getInitialSystemMessage(context.messages);const initialSystemText = initialSystemMessage ? getSystemMessageText(initialSystemMessage) : "";const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);const conversationMessages = initialSystemMessage ? transformedMessages.slice(1) : transformedMessages;const params: MessageCreateParamsStreaming = { model: model.id, messages: effortMessages ? insertThinkingLevelMessages(converted, activeEffort) : converted.messages, max_tokens: options?.maxTokens ?? model.maxTokens, stream: true, ...(betaFeatures.length > 0 ? { betas: betaFeatures } : {}),};// For OAuth tokens, we MUST include Claude Code identityif (isOAuthToken) { params.system = [ { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude.", ...(cacheControl ? { cache_control: cacheControl } : {}), }, ]; if (initialSystemText) { params.system.push({ type: "text", text: sanitizeSurrogates(initialSystemText), ...(cacheControl ? { cache_control: cacheControl } : {}), }); }} else if (initialSystemText) { // Add cache control to system prompt for non-OAuth tokens params.system = [ { type: "text", text: sanitizeSurrogates(initialSystemText), ...(cacheControl ? { cache_control: cacheControl } : {}), }, ];}transformMessages() chạy đầu tiên (chuẩn hoá lịch sử cho mô hình đích, xem phần chuyển giao), kèm hàm normalizeToolCallId thay ký tự lạ bằng _ và cắt còn 64 ký tự, vì Anthropic chỉ nhận id khớp ^[a-zA-Z0-9_-]+$ (anthropic-messages.ts). System prompt đi riêng trong trường system, mang một điểm ngắt cache (cache_control). Tool được gửi theo danh sách hiện tại; khi mô hình hỗ trợ thay đổi tool gốc (native) giữa hội thoại, danh sách ban đầu được giữ cố định cùng một tool giữ chỗ bị hoãn (deferred) không bao giờ dùng, và các tool sau được khai báo trong khối tool_addition để tiền tố cache không đổi (anthropic-messages.ts).
Mỗi định nghĩa tool gồm tên (đổi cách viết hoa nếu là OAuth), mô tả, input_schema, cờ strict khi được bật, và hai trường đáng chú ý (convertTools). eager_input_streaming yêu cầu Anthropic truyền tham số tool ngay khi sinh ra; provider tương thích nào không nhận trường này thì đặt compat.supportsEagerToolInputStreaming: false và adapter quay về beta header fine-grained-tool-streaming. cache_control chỉ gắn vào tool cuối cùng: một điểm ngắt cache phủ cả danh sách tool phía trước nó.
if (thinkingDisabled) { // No effort is sent, so the model default applies. Anthropic rejects `block_binding` // with disabled thinking. params.thinking = { type: "disabled" };} else if (effortMessages) { // Managed effort models use adaptive thinking so prefix mismatches can // be dropped instead of surfacing as persistent 400 responses. params.thinking = { type: "adaptive", display: options?.thinkingDisplay ?? "summarized", block_binding: { prefix_mismatch_behavior: "drop_block" }, }; params.output_config = { effort: "high" };} else if (model.reasoning && options?.thinkingEnabled) { // Default to "summarized" so Opus 4.7 and Mythos Preview behave like // older Claude 4 models (whose API default is also "summarized"). const display: AnthropicThinkingDisplay = options.thinkingDisplay ?? "summarized"; if (model.compat?.forceAdaptiveThinking === true) { // Adaptive thinking: Claude decides when and how much to think. params.thinking = { type: "adaptive", display }; if (options.effort) { params.output_config = { effort: options.effort }; } } else { // Budget-based thinking for older models params.thinking = { type: "enabled", budget_tokens: options.thinkingBudgetTokens || 1024, display, }; }}Có bốn nhánh. Mô hình có suy nghĩ mà người gọi tắt (và thinkingLevelMap.off không phải null) nhận thinking: { type: "disabled" }. Mô hình đổi được effort giữa hội thoại (compat.supportsMidConvoEffort) luôn dùng adaptive với effort: "high" cố định ở cấp request; mức thật đi trong các system message chỉ chứa output_config, một cái trước mỗi câu trả lời cũ có ghi providerThinkingLevel và một cái ở cuối với mức hiện tại, để đổi mức suy nghĩ không làm đổi phần đầu đã cache (insertThinkingLevelMessages). Mô hình "adaptive" khác (compat.forceAdaptiveThinking) tự quyết định nghĩ bao nhiêu và chỉ nhận một mức effort trong output_config. Mô hình cũ nhận budget_tokens. Pi mặc định display: "summarized" dù API của các Claude mới mặc định là "omitted", để mọi mô hình Claude 4 trở lên cư xử như nhau (AnthropicOptions).
Đổi message: convertMessages()
Hàm này đi qua transcript một lần và dịch từng vai. User message bỏ khối chữ rỗng. Assistant message giữ chữ, tool call thành tool_use, còn suy nghĩ cần xử lý cẩn thận nhất:
} else if (block.type === "thinking") { // Redacted thinking: pass the opaque payload back as redacted_thinking if (block.redacted) { blocks.push({ type: "redacted_thinking", data: block.thinkingSignature!, }); continue; } const thinkingSignature = block.thinkingSignature; const hasThinkingSignature = !!thinkingSignature && thinkingSignature.trim().length > 0; if (block.thinking.trim().length === 0 && !hasThinkingSignature) continue; // If thinking signature is missing/empty (e.g., from aborted stream), // convert to plain text for Anthropic. Some compatible providers emit // and accept empty signatures, so let marked models preserve the block. if (!hasThinkingSignature) { blocks.push( allowEmptySignature ? { type: "thinking", thinking: sanitizeSurrogates(block.thinking), signature: "", } : { type: "text", text: sanitizeSurrogates(block.thinking), }, ); } else { blocks.push({ type: "thinking", thinking: sanitizeSurrogates(block.thinking), signature: thinkingSignature, }); }Anthropic chỉ nhận lại khối thinking có chữ ký hợp lệ. Khối bị che được gửi lại nguyên dạng redacted_thinking. Khối có chữ nhưng không có chữ ký (chú thích trong code lấy ví dụ luồng bị huỷ trước khi signature_delta tới; một số provider tương thích cũng trả chữ ký rỗng) bị đổi thành chữ thường, trừ khi provider khai báo allowEmptySignature.
} else if (msg.role === "toolResult") { // Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint. const toolResults: ContentBlockParam[] = []; // Anthropic rejects error tool results that contain anything but text, so their images follow // all tool results of the turn, which must come first in the user message (#10794). const errorImages: ContentBlockParam[] = []; let j = i; while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") { const toolMsg = transformedMessages[j] as ToolResultMessage; const split = splitErrorToolResultImages(toolMsg); toolResults.push(convertToolResult(toolMsg, split?.content)); if (split) { errorImages.push({ type: "text", text: split.caption }, ...split.images.map(convertImageBlock)); } j++; } // Skip the messages we've already processed. i = j - 1; params.push({ role: "user", content: [...toolResults, ...errorImages], });}Ở Anthropic, kết quả tool là khối tool_result nằm trong một message vai user. Nhiều kết quả tool liên tiếp được gom vào cùng một message (endpoint Anthropic của z.ai cần điều này). Anthropic từ chối kết quả tool lỗi có chứa ảnh, nên ảnh của chúng được chuyển ra sau mọi tool_result của lượt, kèm một dòng chú thích (lỗi #10794). System message giữa hội thoại thì được giữ lại và chỉ chèn ngay trước assistant message kế tiếp, vì Anthropic yêu cầu tool_result phải đứng ngay sau tool_use tương ứng (anthropic-messages.ts).
// Add cache_control to the last user or system message to cache conversation historyif (cacheControl && params.length > 0) { const lastMessage = params[params.length - 1]; if (lastMessage.role === "user" || lastMessage.role === "system") { if (Array.isArray(lastMessage.content)) { const lastBlock = lastMessage.content[lastMessage.content.length - 1]; if ( lastBlock && (lastBlock.type === "text" || lastBlock.type === "image" || lastBlock.type === "tool_result" || lastBlock.type === "tool_addition" || lastBlock.type === "tool_removal") ) { (lastBlock as any).cache_control = cacheControl; } } else if (typeof lastMessage.content === "string") { lastMessage.content = [ { type: "text", text: lastMessage.content, cache_control: cacheControl, }, ] as any; } }}Đọc luồng: từ SSE tới sự kiện chuẩn hoá
Adapter tự đọc Server-Sent Events từ response.body: iterateSseMessages() tách dòng (chấp nhận cả \r, \n, \r\n), ghép các dòng data:, kiểm tra signal trước mỗi lần đọc (anthropic-messages.ts). Lớp trên lọc ra sự kiện của Anthropic:
async function* iterateAnthropicEvents( response: Response, signal?: AbortSignal,): AsyncGenerator<RawMessageStreamEvent> { if (!response.body) { throw new Error("Attempted to iterate over an Anthropic response with no body"); } let sawMessageStart = false; let sawMessageEnd = false; for await (const sse of iterateSseMessages(response.body, signal)) { if (sse.event === "error") { throw new Error(sse.data); } if (!ANTHROPIC_MESSAGE_EVENTS.has(sse.event ?? "")) { continue; } try { const event = parseJsonWithRepair<RawMessageStreamEvent>(sse.data); if (event.type === "message_start") { sawMessageStart = true; } else if (event.type === "message_stop") { sawMessageEnd = true; } yield event; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error( `Could not parse Anthropic SSE event ${sse.event}: ${message}; data=${sse.data}; raw=${sse.raw.join("\\n")}`, ); } } if (sawMessageStart && !sawMessageEnd) { throw new Error("Anthropic stream ended before message_stop"); }}Ba hành vi đáng chú ý: sự kiện SSE error giữa luồng thành ngoại lệ; sự kiện không quen như ping bị bỏ qua; JSON hỏng được thử sửa trước khi bỏ cuộc. Nếu đã thấy message_start mà luồng hết trước message_stop, đó là lỗi chứ không phải một câu trả lời ngắn.
Vòng lặp chính dịch từng sự kiện. message_start không phát sự kiện nào ra ngoài, nhưng ghi lại usage ban đầu, để một request bị huỷ sớm vẫn có số token input:
for await (const event of iterateAnthropicEvents(response, options?.signal)) { await options?.onProviderStreamEvent?.(event, model); if (event.type === "message_start") { output.responseId = event.message.id; const transformations = event.message.input_transformations; if (Array.isArray(transformations)) inputTransformations = transformations; const responseModel = event.message.model; if (responseModel !== model.id) output.responseModel = responseModel; const fallbackCost = responseModel === model.id ? undefined : model.compat?.allowedFallbackModels?.find( (fallback) => fallback.provider === model.provider && fallback.model === responseModel, )?.cost; usageModel = fallbackCost ? { ...model, id: responseModel, cost: fallbackCost } : model; // Capture initial token usage from message_start event // This ensures we have input token counts even if the stream is aborted early output.usage.input = event.message.usage.input_tokens || 0; output.usage.output = event.message.usage.output_tokens || 0; output.usage.cacheRead = event.message.usage.cache_read_input_tokens || 0; output.usage.cacheWrite = event.message.usage.cache_creation_input_tokens || 0; output.usage.cacheWrite1h = event.message.usage.cache_creation?.ephemeral_1h_input_tokens || 0; // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; calculateCost(usageModel, output.usage);} else if (event.type === "content_block_start") { if (event.content_block.type === "fallback") { if (output.content.length > 0) { throw new Error("Anthropic performed an unsupported mid-output model fallback"); } continue; } if (event.content_block.type === "text") { const block: Block = { type: "text", text: event.content_block.text ?? "", index: event.index, }; output.content.push(block); stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output }); } else if (event.content_block.type === "thinking") { const block: Block = { type: "thinking", thinking: event.content_block.thinking ?? "", thinkingSignature: event.content_block.signature ?? "", index: event.index, }; output.content.push(block); stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output }); } else if (event.content_block.type === "tool_use") { const block: Block = { type: "toolCall", id: event.content_block.id, name: isOAuth ? fromClaudeCodeName(event.content_block.name, currentTools) : event.content_block.name, arguments: (event.content_block.input as Record<string, any>) ?? {}, partialJson: "", index: event.index, }; output.content.push(block); stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output }); }Mỗi khối nhận thêm một trường tạm index, là chỉ số khối của Anthropic. Chỉ số này không nhất thiết trùng với vị trí trong mảng content của pi: khối fallback bị bỏ qua, khối có kiểu lạ không được thêm vào content. Vì vậy mọi delta đều tìm khối bằng blocks.findIndex((b) => b.index === event.index) rồi phát sự kiện với contentIndex là vị trí trong mảng của pi.
} else if (event.delta.type === "input_json_delta") { const index = blocks.findIndex((b) => b.index === event.index); const block = blocks[index]; if (block && block.type === "toolCall") { block.partialJson += event.delta.partial_json; block.arguments = parseStreamingJson(block.partialJson); stream.push({ type: "toolcall_delta", contentIndex: index, delta: event.delta.partial_json, partial: output, }); }} else if (event.delta.type === "signature_delta") { const index = blocks.findIndex((b) => b.index === event.index); const block = blocks[index]; if (block && block.type === "thinking") { block.thinkingSignature = block.thinkingSignature || ""; block.thinkingSignature += event.delta.signature; }}Mỗi mảnh JSON của tham số được nối vào bộ đệm partialJson và phân tích lại toàn bộ bằng parseStreamingJson(), nên arguments luôn là một object dùng được ngay cả khi JSON còn dở (xem phần partial JSON). signature_delta chỉ nối vào chữ ký, không phát sự kiện nào.
} else if (event.type === "content_block_stop") { const index = blocks.findIndex((b) => b.index === event.index); const block = blocks[index]; if (block) { delete (block as any).index; if (block.type === "text") { stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output, }); } else if (block.type === "thinking") { stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output, }); } else if (block.type === "toolCall") { Object.assign(block, parseToolCallArguments(block.partialJson)); // Finalize in-place and strip the scratch buffer so replay only // carries parsed arguments. delete (block as { partialJson?: string }).partialJson; stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: output, }); } }Ở cuối khối tool, tham số được phân tích lần cuối bằng parseToolCallArguments(), nghiêm ngặt hơn: JSON hỏng thì arguments thành {} và có argumentsError. Bộ đệm partialJson và trường index bị xoá để message lưu vào session không mang rác của quá trình stream.
} else if (event.type === "message_delta") { const transformations = event.input_transformations; if (Array.isArray(transformations)) inputTransformations = transformations; if (event.delta.stop_reason) { output.rawStopReason = event.delta.stop_reason; const stopReasonResult = mapStopReason(event.delta.stop_reason, event.delta.stop_details); output.stopReason = stopReasonResult.stopReason; if (stopReasonResult.errorMessage) { output.errorMessage = stopReasonResult.errorMessage; } } // Anthropic reports reasoning tokens as a subset of output tokens. const thinkingTokens = event.usage.output_tokens_details?.thinking_tokens; if (thinkingTokens != null) { output.usage.reasoning = thinkingTokens; } } // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; calculateCost(usageModel, output.usage);}Anthropic gửi usage cuối trong message_delta. Code chỉ ghi đè những trường khác null, vì một số proxy bỏ input_tokens ở sự kiện này. totalTokens được cộng từ bốn thành phần vì Anthropic không trả tổng, và chi phí được tính lại sau mỗi lần cập nhật.
Kết thúc, lỗi và huỷ
if (options?.signal?.aborted) { throw new Error("Request was aborted"); } if (output.stopReason === "pending") { throw new Error("Anthropic stream ended without a stop reason"); } if (output.stopReason === "aborted" || output.stopReason === "error") { throw new Error(output.errorMessage || "An unknown error occurred"); } if (inputTransformations && inputTransformations.length > 0) { appendAssistantMessageDiagnostic(output, { type: "anthropic_input_transformations", timestamp: Date.now(), details: { transformations: inputTransformations.map((transformation) => ({ type: transformation.type ?? undefined, path: transformation.path ?? undefined, reason: transformation.reason ?? undefined, })), }, }); } stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end();} catch (error) { for (const block of output.content) { delete (block as { index?: number }).index; // partialJson is only a streaming scratch buffer; never persist it. delete (block as { partialJson?: string }).partialJson; } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end();}Mọi đường thất bại đều đổ về một catch: dọn trường tạm, đặt stopReason là aborted nếu tín hiệu huỷ đã bật, ngược lại là error, rồi phát sự kiện error với chính output. Vì output được lấp dần trong lúc đọc, message lỗi giữ nguyên phần chữ, suy nghĩ và usage đã nhận. Đó là "abort trả về message dở dang" mà README nói tới.
Lý do dừng của Anthropic được ánh xạ trong mapStopReason(): end_turn thành stop, max_tokens thành length, tool_use thành toolUse; refusal và sensitive thành lỗi có thông báo; pause_turn được coi như stop; một lý do mới chưa biết làm request thất bại thay vì bị đoán bừa.
streamSimple(): dịch mức suy nghĩ
if (!options?.reasoning) { return stream(model, context, { ...base, thinkingEnabled: false, } satisfies AnthropicOptions);}// For models with adaptive thinking: use an effort level.// For older models: use budget-based thinking.if (model.compat?.forceAdaptiveThinking === true) { const effort = mapThinkingLevelToEffort(model, options.reasoning); return stream(model, context, { ...base, thinkingEnabled: true, effort, } satisfies AnthropicOptions);}// Undefined means the caller did not request an output cap; let the helper use the model cap.// Do not coerce to 0 here, or the thinking budget would become the entire max_tokens value.const adjusted = adjustMaxTokensForThinking( base.maxTokens, model.maxTokens, options.reasoning, options.thinkingBudgets,);const maxTokens = clampMaxTokensToContext(model, context, adjusted.maxTokens);return stream(model, context, { ...base, maxTokens, thinkingEnabled: true, thinkingBudgetTokens: Math.min(adjusted.thinkingBudget, Math.max(0, maxTokens - 1024)),} satisfies AnthropicOptions);Không có reasoning thì tắt suy nghĩ. Mô hình adaptive nhận một effort: lấy từ thinkingLevelMap nếu có, không thì minimal và low thành low, medium thành medium, còn lại thành high (mapThinkingLevelToEffort). Mô hình cũ nhận một ngân sách lấy từ bảng mặc định 1.024, 2.048, 8.192 và 16.384 token cho minimal tới high (simple-options.ts); max_tokens được nới để chứa cả ngân sách lẫn câu trả lời, và ngân sách luôn chừa ít nhất 1.024 token cho câu trả lời.
Trace: một request có một lời gọi tool#
Ghép các mảnh trên lại bằng một kịch bản cụ thể. Ứng dụng có một tool read và hỏi mô hình Claude đọc package.json, với mức suy nghĩ low. Giả sử mô hình dùng thinking theo ngân sách và khoá nằm trong ANTHROPIC_API_KEY.
Ví dụ minh hoạ: lời gọi
const s = models.streamSimple(model, {
systemPrompt: "Bạn là trợ lý lập trình.",
messages: [{ role: "user", content: "Đọc package.json", timestamp: 1761100000000 }],
tools: [readTool], // name "read", parameters { path: string }
}, { reasoning: "low", sessionId: "sess-1" });- Chuẩn hoá
normalizeContext()biến context thành transcript hai message: system message{ content: "Bạn là trợ lý...", toolsAdded: [read], timestamp: 0 }và user message.streamSimple()trả về luồng ngoài ngay lập tức. - Setup trong
lazyStreamrequireChatProvider()tìm provideranthropic;applyAuth()đọc credential đã lưu (không có) rồi tới các biến môi trường, ở đây làANTHROPIC_API_KEY;createProviderchọn adapter duy nhất;lazyApiimportanthropic-messages.tslần đầu. streamSimplecủa adapterbuildBaseOptions()kẹpmaxTokens; mứclowthànhthinkingEnabled: truevới ngân sách 2.048 token.buildParams()Dựng payload bên dưới, gọionPayloadnếu có, gửi quaclient.beta.messages.create(...).asResponse().- HTTP 200Gọi
onResponse, phátstart, bắt đầu đọc SSE.
Ví dụ minh hoạ: payload gửi Anthropic (rút gọn)
{
"model": "claude-sonnet-4-5",
"messages": [{ "role": "user", "content": [
{ "type": "text", "text": "Đọc package.json", "cache_control": { "type": "ephemeral" } } ] }],
"max_tokens": …, // model.maxTokens sau khi kẹp
"stream": true,
"betas": ["interleaved-thinking-2025-05-14"],
"system": [{ "type": "text", "text": "Bạn là trợ lý lập trình.",
"cache_control": { "type": "ephemeral" } }],
"tools": [{ "name": "read", "description": "…", "eager_input_streaming": true,
"input_schema": { "type": "object", "properties": { "path": … }, "required": ["path"] },
"cache_control": { "type": "ephemeral" } }],
"thinking": { "type": "enabled", "budget_tokens": 2048, "display": "summarized" }
}cache_control.Anthropic trả về một chuỗi SSE. Bảng dưới ghép từng sự kiện thô với những gì pi-ai phát ra:
| SSE từ Anthropic | pi-ai phát ra | Trạng thái của output |
|---|---|---|
message_start | không có | responseId, usage input và cache |
content_block_start #0 thinking | thinking_start (0) | content[0] = suy nghĩ rỗng |
content_block_delta thinking_delta | thinking_delta (0) | chữ suy nghĩ lớn dần |
content_block_delta signature_delta | không có | nối thinkingSignature |
content_block_stop #0 | thinking_end (0) | xoá trường tạm index |
content_block_start #1 text, rồi text_delta, content_block_stop | text_start, text_delta, text_end (1) | content[1] = "Để tôi đọc file." |
content_block_start #2 tool_use read | toolcall_start (2) | arguments: {}, partialJson: "" |
input_json_delta {"path": "pack | toolcall_delta (2) | arguments: { path: "pack" } |
input_json_delta age.json"} | toolcall_delta (2) | arguments: { path: "package.json" } |
content_block_stop #2 | toolcall_end (2) | phân tích nghiêm ngặt, xoá partialJson |
message_delta stop_reason: tool_use | không có | stopReason: "toolUse", usage output, chi phí |
message_stop, rồi luồng HTTP đóng | done (reason: "toolUse") | message cuối, có durationMs |
Ví dụ minh hoạ: await s.result()
{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "Cần đọc file trước…", "thinkingSignature": "EqQB…" },
{ "type": "text", "text": "Để tôi đọc file." },
{ "type": "toolCall", "id": "toolu_01…", "name": "read", "arguments": { "path": "package.json" } }
],
"api": "anthropic-messages", "provider": "anthropic", "model": "claude-sonnet-4-5",
"responseId": "msg_01…",
"usage": { "input": 18, "output": 96, "cacheRead": 0, "cacheWrite": 1450, "cacheWrite1h": 0,
"totalTokens": 1564, "cost": { "input": …, "output": …, "cacheRead": 0, "cacheWrite": …, "total": … } },
"stopReason": "toolUse",
"timestamp": 1761100000000,
"durationMs": 2140
}index hay partialJson; các con số là minh hoạ.pi-ai dừng ở đây. Vòng lặp agent đọc khối toolCall, kiểm tra tham số, chạy read, rồi nối hai message vào transcript: chính message trên và một toolResult có toolCallId bằng id của lời gọi. Ở request tiếp theo, convertMessages() dịch assistant message thành các khối thinking (có chữ ký), text và tool_use, dịch kết quả tool thành một user message chứa tool_result, và điểm ngắt cache thứ ba dời sang khối tool_result đó. Nhờ prefix cũ không đổi, request thứ hai đọc lại phần lớn prompt từ cache.
Adapter OpenAI Responses để đối chiếu#
Khung của api/openai-responses.ts giống Anthropic: output rỗng với stopReason: "pending", gửi request qua SDK với maxRetries: 0 bọc trong retryProviderRequest(), phát start sau khi có phản hồi, một catch cho mọi lỗi (openai-responses.ts). Phần đọc luồng nằm trong openai-responses-shared.ts vì Azure và Codex dùng lại nó. Khác biệt nằm ở mô hình dữ liệu của API: Responses không có "khối nội dung" mà có các output item (reasoning, message, function_call), và nó có thể giữ trạng thái hội thoại phía máy chủ.
const params: ResponseCreateParamsStreaming = { model: model.id, input: messages, stream: true, prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId), prompt_cache_retention: omitUnsupportedFields ? undefined : getPromptCacheRetention(compat, cacheRetention), prompt_cache_options: omitUnsupportedFields ? undefined : getPromptCacheOptions(compat, cacheRetention), store: false,};const reasoningEffort = options?.reasoningEffort ?? (options?.reasoningSummary ? "medium" : undefined);if (model.reasoning) { if (reasoningEffort) { const requestedEffort = options?.reasoningEffort ? (model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort) : reasoningEffort; const effort = configurationUpdateEffort === undefined ? requestedEffort : CONFIGURATION_UPDATE_REQUEST_EFFORT; params.reasoning = { effort: effort as NonNullable<typeof params.reasoning>["effort"], summary: options?.reasoningSummary || "auto", }; params.include = ["reasoning.encrypted_content"]; } else if (model.provider !== "github-copilot" && model.thinkingLevelMap?.off !== null) { params.reasoning = { effort: (model.thinkingLevelMap?.off ?? "none") as NonNullable<typeof params.reasoning>["effort"], }; } if (model.provider === "xai") params.include = ["reasoning.encrypted_content"];}Đây là quyết định quan trọng nhất của adapter. Pi gửi store: false, tức không để OpenAI giữ hội thoại, và xin reasoning.encrypted_content để tự giữ phần suy nghĩ đã mã hoá. Mỗi lượt, toàn bộ lịch sử được gửi lại từ transcript cục bộ; reasoning item cũ được phát lại nguyên vẹn từ thinkingSignature (openai-responses-shared.ts). Transcript cục bộ vì thế là nguồn sự thật duy nhất: adapter này không gửi previous_response_id trỏ vào trạng thái mà chỉ máy chủ OpenAI biết. Adapter Codex có một ngoại lệ được kiểm soát: trên một kết nối WebSocket được dùng lại, nó gửi previous_response_id cùng phần input mới, nhưng chỉ khi request mới đúng bằng request trước cộng output của nó; lệch bất kỳ chỗ nào thì gửi lại toàn bộ từ transcript (openai-codex-responses.ts).
if (item.type === "function_call") { const block: StreamingToolCall = { type: "toolCall", id: `${item.call_id}|${item.id}`, name: item.name, arguments: {}, ...(item.namespace !== undefined ? { namespace: item.namespace } : {}), partialJson: item.arguments || "", }; output.content.push(block); const slot = { type: "toolCall", block, contentIndex: output.content.length - 1, } satisfies ResponsesOutputSlot; outputSlots.set(outputIndex, slot); stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output }); return slot;} if (item.type === "reasoning" && slot?.type === "thinking") { const summaryText = item.summary?.map((s) => s.text).join("\n\n") || ""; const contentText = item.content?.map((c) => c.text).join("\n\n") || ""; slot.block.thinking = summaryText || contentText || slot.block.thinking; slot.block.thinkingSignature = JSON.stringify(item); reasoningBlocksById.set(item.id, slot.block); stream.push({ type: "thinking_end", contentIndex: slot.contentIndex, content: slot.block.thinking, partial: output, }); outputSlots.delete(event.output_index);- Mỗi output item được ánh xạ vào một "slot" theo
output_index; delta tìm slot theo chỉ số này, giống vai trò của trường tạmindexbên Anthropic. - Id của lời gọi tool là
call_id|item_id:call_idđể ghép với kết quả tool,item_id(fc_…) để phát lại đúng item. Khi phát lại, id được tách ở dấu|. Với message của một mô hình khác cùng provider và API,item_idbị bỏ để OpenAI không kiểm tra cặp reasoning và tool call; với lời gọi tool đến từ provider khác,item_idđược thay bằngfc_cộng một hash (openai-responses-shared.ts, dòng 360). - Suy nghĩ hiển thị là phần tóm tắt (
reasoning_summary_text.delta), các phần tóm tắt cách nhau bằng\n\n; ởoutput_item.done, chữ được thay bằng bản cuối và cả item được lưu làm chữ ký.
if (response?.usage) { const inputDetails = response.usage.input_tokens_details as | { cached_tokens?: number; cache_write_tokens?: number } | undefined; const cachedTokens = inputDetails?.cached_tokens || 0; const cacheWriteTokens = inputDetails?.cache_write_tokens || 0; output.usage = { // OpenAI includes cached and cache-write tokens in input_tokens, so subtract both. input: Math.max(0, (response.usage.input_tokens || 0) - cachedTokens - cacheWriteTokens), output: response.usage.output_tokens || 0, cacheRead: cachedTokens, cacheWrite: cacheWriteTokens, reasoning: response.usage.output_tokens_details?.reasoning_tokens || 0, totalTokens: response.usage.total_tokens || 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; } calculateCost(model, output.usage); if (options?.applyServiceTierPricing) { const serviceTier = options.resolveServiceTier ? options.resolveServiceTier(response?.service_tier, options.serviceTier) : (response?.service_tier ?? options.serviceTier); options.applyServiceTierPricing(output.usage, serviceTier); } // Map status to stop reason. For incomplete responses, retain the provider's // specific reason so max-output truncation and content filtering stay distinct. const status = response?.status; const incompleteDetails = response?.incomplete_details as { reason?: unknown } | null | undefined; const incompleteReason = typeof incompleteDetails?.reason === "string" ? incompleteDetails.reason : undefined; output.rawStopReason = incompleteReason ? `${status}.${incompleteReason}` : status; const mappedStop = mapStopReason(status, incompleteReason); output.stopReason = mappedStop.stopReason; if (mappedStop.errorMessage === undefined) delete output.errorMessage; else output.errorMessage = mappedStop.errorMessage; if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") { output.stopReason = "toolUse"; }};Hai điểm khác Anthropic. Usage chỉ có ở sự kiện cuối (response.completed hoặc response.incomplete), và input_tokens đã gồm token cache nên phải trừ ra. Responses không có lý do dừng "gọi tool": trạng thái completed thành stop, rồi đổi thành toolUse nếu message có lời gọi tool; incomplete vì max_output_tokens thành length, còn incomplete vì lý do khác là lỗi. Ở bản 0.80 changelog ghi response.incomplete là "length stop" (packages/ai/CHANGELOG.md:880); code v1.4.0 đã hẹp hơn thế.
Adapter còn hai hàng rào an toàn. Luồng hết mà chưa thấy sự kiện kết thúc của response thì là lỗi. Và nếu message dừng với toolUse mà còn lời gọi tool chưa nhận output_item.done, cả request thất bại, vì vòng lặp agent sẽ chạy mọi lời gọi tool trong message cuối và không được chạy một lệnh có tham số bị cắt (openai-responses-shared.ts). Cuối cùng, chi phí được nhân theo service tier: flex một nửa, priority và fast gấp đôi (2,5 lần với gpt-5.5) (openai-responses.ts).
Đào sâu: Sign in with ChatGPT dùng chung adapter này
Provider openai có OAuth "Sign in with ChatGPT". Token OAuth tới thẳng api.openai.com nhưng không bắt đầu bằng sk-, và adapter nhận ra điều đó để bỏ các trường mà đường này từ chối: prompt_cache_retention, prompt_cache_options, max_output_tokens, temperature, và các item configuration_update (isChatGPTSignIn, buildParams). Lỗi hết hạn mức của gói được nối thêm đường dẫn tới trang usage của ChatGPT.
OpenAI Completions và các cờ compat#
Chat Completions là API mà hầu như mọi máy chủ "tương thích OpenAI" nói: Groq, DeepSeek, Together, vLLM, llama.cpp, Ollama, LM Studio, các gateway. "Tương thích" ở đây chỉ gần đúng. Có máy chủ không nhận vai developer, có máy chủ đòi max_tokens thay vì max_completion_tokens, có máy chủ trả suy nghĩ trong reasoning_content, có máy chủ khác gọi nó là reasoning. Adapter giải quyết bằng một bộ cờ compat: tự đoán từ provider và baseUrl, rồi để model.compat ghi đè từng cờ.
return { supportsStore: !isNonStandard, supportsDeveloperRole: isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter), supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isAntLing, supportsUsageInStreaming: true, supportsFinishReason: true, maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", requiresToolResultName: false, requiresAssistantAfterToolResult: false, requiresThinkingAsText: false, requiresReasoningContentOnAssistantMessages: isDeepSeek, thinkingFormat: isDeepSeek ? "deepseek" : isZai ? "zai" : isTogether ? "together" : isAntLing ? "ant-ling" : isOpenRouter ? "openrouter" : "openai",getCompat() lấy kết quả này làm nền và ghi đè bằng mọi cờ có trong model.compat (getCompat), nên một mô hình tự định nghĩa chỉ cần khai báo những gì khác mặc định (README). Các cờ hay gặp và nơi chúng có hiệu lực:
| Cờ | Hành vi |
|---|---|
supportsDeveloperRole | Mô hình có suy nghĩ nhận system prompt ở vai developer; tắt cờ thì dùng system. |
maxTokensField | max_completion_tokens hay max_tokens (packages/ai/src/api/openai-completions.ts:848–855). |
supportsUsageInStreaming, supportsStore | Gửi stream_options.include_usage và store: false hay không (packages/ai/src/api/openai-completions.ts:840–846). |
supportsFinishReason | Máy chủ không gửi finish_reason thì pi tự suy ra stop hay toolUse khi luồng hết (packages/ai/src/api/openai-completions.ts:698–706). |
requiresToolResultName, requiresAssistantAfterToolResult | Thêm trường name vào kết quả tool; chèn một assistant message giả "I have processed the tool results." giữa kết quả tool và user message (packages/ai/src/api/openai-completions.ts:1250–1255). |
requiresThinkingAsText, requiresReasoningContentOnAssistantMessages | Phát lại suy nghĩ thành chữ thường; thêm reasoning_content: "" vào mọi assistant message (DeepSeek). |
thinkingFormat | Mức suy nghĩ đi vào trường nào của request (bảng dưới). |
cacheControlFormat: "anthropic" | Gắn cache_control kiểu Anthropic cho Claude qua OpenRouter (packages/ai/src/api/openai-completions.ts:1095–1103). |
supportsMidConvoSystemMessages, supportsMidConvoToolAdditions | Giữ system message giữa hội thoại; thêm tool bằng system message có trường tools kiểu Kimi (packages/ai/src/api/openai-completions.ts:1257–1266). |
Riêng mức suy nghĩ có tới mười một cách gửi, chọn bằng thinkingFormat (buildParams):
thinkingFormat | Trường gửi đi | Tự chọn cho |
|---|---|---|
openai (mặc định) | reasoning_effort | OpenAI và các máy chủ chưa biết |
openrouter | reasoning: { effort } | OpenRouter |
deepseek | thinking: { type } cộng reasoning_effort | DeepSeek |
zai | thinking: { type, clear_thinking } | z.ai |
together | reasoning: { enabled } | Together |
ant-ling | reasoning: { effort } chỉ khi được ánh xạ | Ant Ling |
qwen, qwen-chat-template, chat-template, baseten, string-thinking | enable_thinking, chat_template_kwargs, chat_template_args, hoặc thinking dạng chuỗi | chỉ khi khai báo trong compat |
Phần đọc luồng cũng phải đoán. Suy nghĩ có thể nằm ở reasoning_content (llama.cpp), reasoning hay reasoning_text; code lấy trường đầu tiên khác rỗng để không nhân đôi khi máy chủ gửi cả hai, và lưu tên trường vào thinkingSignature để lần sau phát lại đúng trường đó:
// Handle reasoning before content: some proxies (e.g. LiteLLM) aggregate reasoning and// answer into one delta, and the thinking block must precede the text block.// Some endpoints return reasoning in reasoning_content (llama.cpp),// or reasoning (other openai compatible endpoints)// Use the first non-empty reasoning field to avoid duplication// (e.g., chutes.ai returns both reasoning_content and reasoning with same content)const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"];const deltaFields = choice.delta as Record<string, unknown>;let foundReasoningField: string | null = null;for (const field of reasoningFields) { const value = deltaFields[field]; if (typeof value === "string" && value.length > 0) { foundReasoningField = field; break; }}if (foundReasoningField) { const delta = deltaFields[foundReasoningField]; if (typeof delta === "string" && delta.length > 0) { const thinkingSignature = model.provider === "opencode-go" && foundReasoningField === "reasoning" ? "reasoning_content" : foundReasoningField; const block = ensureThinkingBlock(thinkingSignature); block.thinking += delta; stream.push({ type: "thinking_delta", contentIndex: getContentIndex(block), delta, partial: output, }); }}Suy nghĩ được xử lý trước nội dung của cùng delta, vì một số proxy như LiteLLM gộp cả hai vào một chunk và khối suy nghĩ phải đứng trước khối chữ. Lời gọi tool thì được ghép theo tool_calls[i].index, hoặc theo id khi máy chủ không gửi index. Id lời gọi tool đến từ Responses (dạng call|item, có thể dài hơn 400 ký tự) phải được rút gọn khi phát lại vào Chat Completions:
const normalizeToolCallId = (id: string): string => { // Handle pipe-separated IDs from OpenAI Responses API // Format: {call_id}|{id} where {id} can be 400+ chars with special chars (+, /, =) // These come from providers like github-copilot, openai-codex, opencode // Extract just the call_id part and normalize it // Multiple tool calls in the same turn can share call_id but differ by item_id. // Preserve item-level uniqueness when replaying into Chat Completions, which // requires distinct tool call ids. if (id.includes("|")) { // Sanitize to allowed chars and truncate to 40 chars (OpenAI limit) const separatorIndex = id.indexOf("|"); const callId = id.slice(0, separatorIndex).replace(/[^a-zA-Z0-9_-]/g, "_"); const itemId = id.slice(separatorIndex + 1).replace(/[^a-zA-Z0-9_-]/g, "_"); const combinedId = itemId.length > 0 ? `${callId}_${itemId}` : callId; if (combinedId.length <= 40) { return combinedId; } const hash = shortHash(id).slice(0, 8); const prefix = callId.slice(0, Math.max(1, 40 - hash.length - 1)); return `${prefix}_${hash}`; } if (model.provider === "openai") return id.length > 40 ? id.slice(0, 40) : id; return id;};Usage là chỗ các máy chủ khác nhau nhiều nhất. OpenAI và OpenRouter đặt token đọc cache trong prompt_tokens_details.cached_tokens, DeepSeek dùng prompt_cache_hit_tokens, Kimi đặt cached_tokens ở cấp trên cùng, Moonshot có khi đặt usage trong choice.usage:
const promptTokens = rawUsage.prompt_tokens || 0;const cacheReadTokens = rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? rawUsage.cached_tokens ?? 0;const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0;// Follow documented OpenAI/OpenRouter semantics: cached_tokens is cache-read// tokens (hits). Providers disagree on placement: OpenAI/OpenRouter use// prompt_tokens_details.cached_tokens, DeepSeek uses prompt_cache_hit_tokens,// and Kimi documents top-level usage.cached_tokens on the final usage chunk.// OpenAI does not document or emit cache_write_tokens, but// OpenRouter-compatible providers can include it as a separate write count.// OpenRouter's own provider/tests affirm the separate mapping:// https://github.com/OpenRouterTeam/ai-sdk-provider/pull/409// Do not subtract writes from cached_tokens, otherwise spec-compliant// providers are under-reported. DS4 mirrors this contract too:// https://github.com/antirez/ds4/pull/29const input = Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens);// OpenAI completion_tokens already includes reasoning_tokens.const outputTokens = rawUsage.completion_tokens || 0;const usage: AssistantMessage["usage"] = { input, output: outputTokens, cacheRead: cacheReadTokens, cacheWrite: cacheWriteTokens, reasoning: rawUsage.completion_tokens_details?.reasoning_tokens || 0, totalTokens: input + outputTokens + cacheReadTokens + cacheWriteTokens, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },};calculateCost(model, usage);return usage;Khi transcript có lịch sử tool nhưng request hiện tại không còn tool nào, adapter vẫn gửi tools: [], vì Anthropic đứng sau một proxy kiểu LiteLLM từ chối message có tool_calls mà không có tham số tools (packages/ai/src/api/openai-completions.ts:866–869).
Tham số tool truyền dần: partial JSON#
Khi mô hình gọi tool write với một file 300 dòng, tham số tới thành hàng trăm mảnh JSON trong nhiều giây. Nếu chỉ phân tích JSON khi lời gọi xong, giao diện phải đứng im suốt thời gian đó. Bài viết của Mario nêu đây là một lý do pi-ai phân tích JSON dở dang: giao diện có thể hiện đường dẫn file và nội dung đang được viết ngay khi chúng xuất hiện. Có hai hàm, cho hai thời điểm khác nhau:
export function parseToolCallArguments(json: string | undefined): Pick<ToolCall, "arguments" | "argumentsError"> { if (!json?.trim()) return { arguments: {} }; try { const parsed = parseJsonWithRepair<ToolCall["arguments"] | null>(json); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new Error("Tool arguments must be a JSON object"); } return { arguments: parsed }; } catch (error) { return { arguments: {}, argumentsError: `${error instanceof Error ? error.message : String(error)}\n\nReceived arguments:\n${json}`, }; }}export function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T { if (!partialJson || partialJson.trim() === "") { return {} as T; } try { return parseJsonWithRepair<T>(partialJson); } catch { try { const result = partialParse(partialJson); return (result ?? {}) as T; } catch { try { const result = partialParse(repairJson(partialJson)); return (result ?? {}) as T; } catch { return {} as T; } } }}parseStreamingJson()chạy sau mỗi delta và không bao giờ thất bại: thửJSON.parse(có sửa lỗi), rồi thư việnpartial-json, rồipartial-jsontrên chuỗi đã sửa, cuối cùng trả{}.parseToolCallArguments()chạy một lần khi khối tool kết thúc. Nó không chấp nhận tiền tố dở: chuỗi cuối không phải JSON object thìargumentslà{}vàargumentsErrorghi lỗi kèm chuỗi đã nhận. Trước bản 1.3.0, lời gọi hỏng chạy với những trường đã phân tích được trước chỗ lỗi (packages/ai/CHANGELOG.md:67).repairJson()sửa hai lỗi hay gặp trong chuỗi JSON mà mô hình sinh ra: ký tự điều khiển chưa escape (xuống dòng thật bên trong chuỗi) và escape không hợp lệ, ví dụ\usepackagetrong mã LaTeX, bằng cách nhân đôi dấu gạch ngược (repairJson).
Ví dụ minh hoạ: partialJson sau từng delta → arguments
{"path": "src/ma → { path: "src/ma" }
{"path": "src/main.ts", "con → { path: "src/main.ts" }
{"path": "src/main.ts", "content": "a\nb → { path: "src/main.ts", content: "a\nb" }
{"path": "src/main.ts", "content": "a\nb"} → toolcall_end: parseToolCallArguments() chốtarguments lúc đang truyền phải phòng thủ (packages/ai/README.md:686–693).Google là ngoại lệ: API của nó trả nguyên lời gọi hàm trong một lần, nên adapter phát toolcall_start, đúng một toolcall_delta chứa toàn bộ JSON, rồi toolcall_end liền nhau. Nếu Google không gửi id hoặc id bị trùng, adapter tự tạo id dạng <tên>_<thời gian>_<số đếm> (google-generative-ai.ts).
Lỗi, abort và tràn ngữ cảnh#
Thông báo lỗi đọc được
Lỗi HTTP sau một proxy thường mất phần body: SDK chỉ còn lại "403 status code (no body)". normalizeProviderError() dò các trường mà từng SDK dùng để cất status và body (Mistral, openai, @google/genai, AWS), và formatProviderError() ghép chúng thành "OpenAI API error (403): <body>", cắt body ở 4.000 ký tự (error-body.ts). Adapter Anthropic dùng thẳng error.message vì SDK của nó đã gộp body vào message. Thông báo lỗi quan trọng vì các tầng trên phân loại lỗi bằng chính chuỗi này: thử lại hay không, có phải tràn ngữ cảnh hay không.
Hai tầng thử lại
function isRetryableProviderError(error: ProviderError): boolean { const shouldRetry = error.headers?.get("x-should-retry"); if (shouldRetry === "true") return true; if (shouldRetry === "false") return false; if (error.status === undefined) return true; return ( error.status === 408 || error.status === 409 || error.status === 429 || (typeof error.status === "number" && error.status >= 500) );}export async function retryProviderRequest<T>( request: () => Promise<T>, options: ProviderRetryOptions = {},): Promise<T> { const maxRetries = options.maxRetries ?? 0; let retriesRemaining = maxRetries; for (;;) { try { // Each retry is a fresh SDK request, so X-Stainless-Retry-Count remains zero. return await request(); } catch (error) { if (options.signal?.aborted) throw createAbortError(); if (retriesRemaining <= 0 || !isProviderError(error) || !isRetryableProviderError(error)) throw error; if (error.status !== undefined && options.noRetryStatuses?.includes(error.status)) throw error; const retryIndex = maxRetries - retriesRemaining; retriesRemaining--; await abortableSleep(getRetryDelayMs(error, retryIndex, options.maxRetryDelayMs), options.signal); } }}Tầng thứ nhất nằm trong adapter và chỉ bọc lời gọi tạo request, trước khi có byte nào của luồng: thử lại với 408, 409, 429, 5xx hoặc lỗi kết nối mà SDK báo không kèm status (header x-should-retry của máy chủ được ưu tiên), tôn trọng retry-after, và thất bại ngay nếu máy chủ đòi chờ lâu hơn maxRetryDelayMs (mặc định 60 giây). Mặc định của maxRetries ở đây là 0: không truyền gì thì không thử lại. Tầng thứ hai làm việc trên message lỗi đã hoàn chỉnh: isRetryableAssistantError() đọc errorMessage, loại các lỗi hết hạn mức hay hết tiền, rồi khớp với các mẫu tạm thời như overloaded hay rate limit; retryAssistantCall() chạy lại cả lượt theo một RetryPolicy giống settings.retry của coding agent (retry.ts).
Huỷ giữa chừng
Gọi controller.abort() làm SDK dừng đọc, vòng đọc ném lỗi, và catch của adapter phát error với reason: "aborted". Message giữ lại mọi khối đã nhận và usage đến lúc đó, có thể cất vào session để hiển thị. Nhưng khi gửi lịch sử cho mô hình ở request sau, transformMessages() bỏ hẳn mọi assistant message có stopReason là error hoặc aborted, vì phát lại một lượt dở (suy nghĩ không có câu trả lời, lời gọi tool bị cắt) có thể khiến API báo lỗi (transform-messages.ts). Ví dụ "Continuing After Abort" trong README đẩy message dở vào context rồi bảo mô hình "Please continue" (packages/ai/README.md:1208–1231); với code v1.4.0, mô hình mới không thấy phần chữ dở đó.
Tràn ngữ cảnh
Khi prompt vượt cửa sổ ngữ cảnh, mỗi hãng báo một kiểu. pi-ai không ném một loại lỗi riêng mà cung cấp một hàm nhận diện, để người gọi (compaction của coding agent, xem chương Compaction) quyết định nén rồi thử lại:
export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean { // Case 1: Check error message patterns if (message.stopReason === "error" && message.errorMessage) { // Skip messages matching known non-overflow patterns (e.g. throttling / rate-limit) const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!)); if (!isNonOverflow) { if (OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!))) { return true; } if (message.provider === "cerebras" && CEREBRAS_BODYLESS_OVERFLOW_PATTERN.test(message.errorMessage)) { return true; } } } // Case 2: Silent overflow (z.ai style) - successful but usage exceeds context if (contextWindow && message.stopReason === "stop") { const inputTokens = message.usage.input + message.usage.cacheRead; if (inputTokens > contextWindow) { return true; } } // Case 3: Length-stop overflow (Xiaomi MiMo style) - server truncates oversized input // to fit the context window, leaving no room for output. Returns stopReason "length" // with output=0 and input+cacheRead filling the context window. if (contextWindow && message.stopReason === "length" && message.usage.output === 0) { const inputTokens = message.usage.input + message.usage.cacheRead; if (inputTokens >= contextWindow * 0.99) { return true; } } return false;}Cách thứ nhất là khớp errorMessage với 26 mẫu regex, mỗi mẫu ghi chú hãng tương ứng, sau khi loại các lỗi giới hạn tốc độ có chữ trùng (Bedrock báo throttling bằng "Too many tokens"). Mọi HTTP 413 đều được coi là tràn (OVERFLOW_PATTERNS). Cách thứ hai bắt "tràn lặng lẽ" của z.ai: request thành công nhưng input + cacheRead lớn hơn cửa sổ. Cách thứ ba bắt Xiaomi MiMo: máy chủ tự cắt input cho vừa cửa sổ, rồi dừng với length và 0 token output. Hai cách sau chỉ chạy khi người gọi truyền contextWindow.
Chuyển giao giữa các provider#
Một session của Pi có thể bắt đầu với Claude, chuyển sang GPT ở giữa, rồi sang Gemini. Transcript chỉ có một, nhưng mỗi nhà cung cấp có ràng buộc riêng: chữ ký suy nghĩ chỉ hợp lệ với mô hình đã tạo ra nó, id lời gọi tool của OpenAI Responses có thể dài hàng trăm ký tự và chứa | trong khi Anthropic chỉ nhận 64 ký tự [a-zA-Z0-9_-], và có API từ chối lời gọi tool không có kết quả. Mọi adapter vì thế gọi transformMessages() trước khi đổi định dạng:
const assistantMsg = msg as AssistantMessage;const isSameModel = assistantMsg.provider === model.provider && assistantMsg.api === model.api && assistantMsg.model === model.id;const transformedContent = assistantMsg.content.flatMap((block) => { if (block.type === "thinking") { // Thinking from another model is dropped. Signatures and encrypted reasoning are // only valid for the model that produced them, and sending readable thinking as // plain text makes the new model treat it as part of the visible reply. if (!isSameModel) return []; // Keep redacted and signed blocks even if the thinking text is empty // (OpenAI encrypted reasoning); they are needed for replay. if (block.redacted || block.thinkingSignature) return block; if (!block.thinking || block.thinking.trim() === "") return []; return block; } if (block.type === "toolCall") { const toolCall = block as ToolCall; let normalizedToolCall: ToolCall = toolCall; if (!isSameModel && toolCall.thoughtSignature) { normalizedToolCall = { ...toolCall }; delete (normalizedToolCall as { thoughtSignature?: string }).thoughtSignature; } if (!isSameModel && normalizeToolCallId) { const normalizedId = normalizeToolCallId(toolCall.id, model, assistantMsg); if (normalizedId !== toolCall.id) { toolCallIdMap.set(toolCall.id, normalizedId); normalizedToolCall = { ...normalizedToolCall, id: normalizedId }; } } return normalizedToolCall; }"Cùng mô hình" nghĩa là trùng cả provider, api và id. Với message của mô hình khác: khối suy nghĩ bị bỏ hẳn; khối chữ mất textSignature; lời gọi tool mất thoughtSignature của Google và được đổi id theo quy tắc của adapter đích, với ánh xạ id cũ sang id mới được nhớ lại để sửa toolCallId của kết quả tool phía sau. Trước bản 1.3.0, suy nghĩ của mô hình khác được gửi thành chữ thường; điều đó làm mô hình mới coi suy nghĩ cũ là một phần câu trả lời và bắt đầu viết suy nghĩ của chính nó ra phần hiển thị (packages/ai/CHANGELOG.md:53).
const closePendingToolCalls = () => { if (pendingToolCalls.length > 0) { for (const tc of pendingToolCalls) { if (!existingToolResultIds.has(tc.id)) { result.push({ role: "toolResult", toolCallId: tc.id, toolName: tc.name, content: [{ type: "text", text: "No result provided" }], isError: true, timestamp: Date.now(), } as ToolResultMessage); } } pendingToolCalls = []; existingToolResultIds = new Set(); } result.push(...heldSystemMessages); heldSystemMessages.length = 0;}; // Skip errored/aborted assistant messages entirely. // These are incomplete turns that shouldn't be replayed: // - May have partial content (reasoning without message, incomplete tool calls) // - Replaying them can cause API errors (e.g., OpenAI "reasoning without following item") // - The model should retry from the last valid state const assistantMsg = msg as AssistantMessage; if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") { continue; }Lượt thứ hai sửa cấu trúc. Một lời gọi tool không có kết quả (ví dụ tiến trình dừng khi tool đang chạy, nên session có lời gọi mà không có kết quả) nhận một kết quả giả "No result provided" với isError: true, vì hầu hết API từ chối tool_use mồ côi. System message nằm giữa lời gọi tool và kết quả của nó được dời ra sau các kết quả. Assistant message lỗi hoặc bị huỷ bị bỏ, như đã nói ở phần trên.
| Đổi từ | Sang | Điều xảy ra với lời gọi tool cũ |
|---|---|---|
OpenAI Responses (call_abc|fc_…, hơn 400 ký tự) | Anthropic | Ký tự lạ thành _, cắt còn 64 ký tự |
| OpenAI Responses | Chat Completions | callId_itemId nếu vừa 40 ký tự, không thì tiền tố cộng hash 8 ký tự |
Anthropic (toolu_…) | OpenAI Responses | Giữ id sau khi làm sạch; không có item_id để phát lại |
| Bất kỳ | Bất kỳ | Suy nghĩ của mô hình cũ bị bỏ; chữ và lời gọi tool được giữ |
Catalog mô hình, khoá API và OAuth#
Catalog sinh tự động
Danh sách mô hình có sẵn không được viết tay. scripts/generate-models.ts tải dữ liệu từ models.dev (generate-models.ts), danh sách của OpenRouter và Vercel AI Gateway, áp các điều chỉnh riêng của từng hãng (cờ compat, bảng mức suy nghĩ, bậc giá), rồi ghi một file JSON cho mỗi provider vào src/providers/data/. Thư mục này nằm trong .gitignore (.gitignore:11), nên bản clone ở tag v1.4.0 không có dữ liệu mô hình; nó được sinh lại khi build. Phần được commit là các wrapper ổn định như anthropic.models.ts, dùng chính khoá JSON để suy ra kiểu chính xác của từng id mô hình (anthropic.models.ts), và models.generated.ts gom tất cả lại. Chỉ mô hình gọi được tool mới vào catalog chat (packages/ai/README.md:5).
Có ba cách đọc catalog: models.getModel(provider, id) trên một Models (gồm cả mô hình động), getBuiltinModel() từ providers/all với kiểu literal đầy đủ (packages/ai/src/providers/all.ts:61–69), và getModel() cũ của compat, nay chỉ là tên khác đã deprecated của getBuiltinModel (packages/ai/src/compat.ts:64–71).
Lớp phủ catalog từ pi.dev
Catalog đóng gói theo bản phát hành sẽ cũ đi: một mô hình mới ra hôm nay không có trong bản Pi cài tuần trước. Coding agent bọc mỗi provider có sẵn (trừ radius, và openrouter được cá nhân hoá theo khoá) bằng withRemoteCatalog() (model-runtime.ts). Code này thuộc coding agent, nhưng nó chỉ dùng các hook công khai của pi-ai: Provider.refreshModels(), RefreshModelsContext.publish() và ModelsStore.
refreshModels: async (context) => { const stored = context.stored; const restored = remoteModels(stored, localGeneratedAt).filter((model) => model.provider === provider.id); if ( !(await context.publish({ update: () => { dynamicModels = restored; }, })) ) { return; } if (!context.allowNetwork || context.signal.aborted) return; // Skip the request if pi.dev was checked less than REMOTE_CATALOG_REFRESH_INTERVAL_MS ago. // An entry without lastModified (saved by an older pi, or after a failed first request) is // always refetched: remoteModels() ignores it, so waiting would leave the overlay empty. if ( !context.force && stored?.checkedAt !== undefined && stored.lastModified !== undefined && Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS ) { return; } // Only revalidate when a cached body backs the validator, so a 304 can never // leave the overlay empty. const validator = stored && stored.models.length > 0 ? stored.etag : undefined; const response = await requestCatalog(provider.id, catalogBaseUrl, context.signal, validator); if (context.signal.aborted) return; const checkedAt = Date.now(); // Unchanged: dynamicModels already holds the stored overlay, so only the // freshness window moves. if (response.status === 304 && stored) { await context.publish({ persist: { ...stored, checkedAt } }); return; }Mỗi lần làm mới, bản đã lưu được khôi phục trước tiên. Request tới https://pi.dev/api/models/providers/<id>?types=chat,image,classifier chỉ được gửi nếu lần kiểm tra trước đã quá bốn giờ (packages/coding-agent/src/core/remote-catalog-provider.ts:16), kèm If-None-Match để máy chủ trả 304 khi không có gì mới. Lớp phủ chỉ có hiệu lực khi Last-Modified của catalog từ xa mới hơn thời điểm sinh dữ liệu đóng gói (isNewer), nên một bản Pi mới không bị catalog cũ trên mạng kéo lùi. Mô hình từ xa ghi đè mô hình tĩnh cùng loại và id.
Mô hình tự định nghĩa
Mô hình là dữ liệu, nên thêm một máy chủ cục bộ chỉ cần một object Model cùng createProvider() với một lazy wrapper có sẵn, như ví dụ Ollama ở phần Data model. Danh sách động (llama.cpp, gateway) dùng fetchModels. Coding agent đọc các khai báo này từ models.json; phần đó thuộc chương AgentSession.
Khoá API từ môi trường
Trong runtime mới, mỗi provider tự khai báo cách tìm khoá. Phần lớn dùng envApiKeyAuth():
export function envApiKeyAuth(name: string, envVars: readonly string[]): ApiKeyAuth { return { name, login: async (interaction) => { interaction.signal.throwIfAborted(); const key = await interaction.prompt({ type: "secret", message: `Enter ${name}` }); interaction.signal.throwIfAborted(); return { type: "api_key", key }; }, resolve: async ({ ctx, credential, signal }) => { signal.throwIfAborted(); if (credential?.key) { return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" }; } for (const envVar of envVars) { const value = await ctx.env(envVar); signal.throwIfAborted(); if (value) return { auth: { apiKey: value }, source: envVar }; } return undefined; }, };}Provider có nguồn xác thực đặc biệt tự viết resolve(). Anthropic là ví dụ: ANTHROPIC_AUTH_TOKEN thành header Authorization: Bearer, rồi tới ANTHROPIC_OAUTH_TOKEN và ANTHROPIC_API_KEY, cuối cùng là bộ biến của workload identity federation, khi đó SDK tự đổi token (anthropic.ts). Bedrock và Vertex đọc credential môi trường của AWS và Google. Bảng tên biến đầy đủ ở README; hàm cũ getEnvApiKey() chỉ còn ở compat (env-api-keys.ts). Biến môi trường được đọc qua AuthContext, và options.env phủ lên trên nó, nên một tiến trình có thể dùng cấu hình khác nhau cho từng request.
Đăng nhập OAuth bằng gói thuê bao
Anthropic (Claude Pro/Max), OpenAI (Sign in with ChatGPT), OpenAI Codex, GitHub Copilot, xAI, Kimi, Meta, OpenRouter và Radius có đăng nhập OAuth. Mỗi cái là một OAuthAuth với ba thao tác: login() tương tác với người dùng qua giao thức trung lập prompt()/notify(), refresh() đổi refresh token lấy token mới, toAuth() suy ra xác thực của request từ credential (packages/ai/src/auth/types.ts:216–245).
export const anthropicOAuth: OAuthAuth = { name: "Anthropic (Claude Pro/Max)", isSubscription: true, async login(interaction) { const method = await interaction.prompt({ type: "select", message: "Select Anthropic login method:", options: [ { id: ANTHROPIC_BROWSER_LOGIN_METHOD, label: "Browser login (default)" }, { id: ANTHROPIC_COPY_CODE_LOGIN_METHOD, label: "Copy code login (headless)" }, ], }); if (method === ANTHROPIC_COPY_CODE_LOGIN_METHOD) { return loginAnthropicCopyCode(interaction); } if (method !== ANTHROPIC_BROWSER_LOGIN_METHOD) { throw new Error(`Unknown Anthropic login method: ${method}`); } return loginAnthropic(interaction); }, refresh: (credential, signal) => refreshAnthropicToken(credential.refresh, signal), async toAuth(credential) { return { apiKey: credential.access }; },};Đăng nhập trên trình duyệt dùng PKCE và một máy chủ callback cục bộ trên cổng 53692, lùi về một cổng trống bất kỳ, hoặc về việc dán URL chuyển hướng nếu không mở được cổng (loginAnthropic). Cách "copy code" để Anthropic hiển thị mã, hợp với máy không có trình duyệt. Token lưu với thời hạn bị trừ trước năm phút. toAuth() trả access token như một khoá API thường; adapter nhận ra nó vì chuỗi chứa sk-ant-oat và chuyển sang chế độ Claude Code đã tả ở trên.
Ba chi tiết thiết kế làm phần này an toàn khi chạy song song. Các flow OAuth được nạp qua import() với tên module là biến, để bundler cho trình duyệt không kéo code chỉ chạy trên Node vào (load.ts). Làm mới token chạy bên trong CredentialStore.modify(), là đường ghi duy nhất và được tuần tự hoá, với điều kiện hết hạn được kiểm tra lại sau khi đã giữ khoá, nên hai request hay hai tiến trình không bao giờ cùng làm mới một refresh token đã xoay vòng. Và một lần làm mới đã bắt đầu sẽ chạy tới cùng dù người gọi huỷ, vì nhà cung cấp có thể đã vô hiệu refresh token cũ (refreshStoredOAuthCredential).
Danh tính của client
Từ bản 1.4.0, request tự giới thiệu bằng User-Agent: pi/<phiên bản> (<nền tảng>; <runtime>; <kiến trúc>), và có thể kèm header ghi nhận ứng dụng (attribution) cho các gateway có bảng xếp hạng ứng dụng:
/** Provider attribution headers of an identity for a request target. Empty when attribution is off. */export function getAttributionHeaders(target: ClientIdentityTarget, identity?: ClientIdentity): Record<string, string> { const attribution = identity?.attribution; if (!attribution) return {}; const { name, url, categories } = attribution; if (isOpenRouter(target)) { return { "HTTP-Referer": url, "X-OpenRouter-Title": name, ...(categories === undefined || categories.length === 0 ? {} : { "X-OpenRouter-Categories": categories.join(",") }), }; } // NVIDIA expects a capitalized origin, such as `Pi`. if (isNvidiaNim(target)) return { "X-BILLING-INVOKE-ORIGIN": name.charAt(0).toUpperCase() + name.slice(1) }; if (isOpenCode(target)) return { "x-opencode-client": name }; return {};}/** * Default headers an identity adds to a request: `User-Agent` and provider attribution. APIs apply them below model, * provider, and request headers, so configured headers override them and `null` request headers remove them. */export function getIdentityHeaders(target: ClientIdentityTarget, identity?: ClientIdentity): Record<string, string> { return { "User-Agent": formatUserAgent(identity), ...getAttributionHeaders(target, identity) };}Mặc định không có attribution. pi-agent-core, Pi Durable và coding agent đặt PI_ATTRIBUTION, tức { name: "pi", url: "https://pi.dev", categories: ["cli-agent"] } (packages/ai/src/utils/identity.ts:23–24). Header của identity nằm dưới header của provider, mô hình và request, nên header cấu hình luôn thắng. Một số provider giữ danh tính cố định của riêng mình, ví dụ GitHub Copilot và Anthropic OAuth (packages/ai/README.md:454).
Prompt caching#
Prompt cache của nhà cung cấp là cache của một tiền tố token chính xác: request sau chỉ đọc được cache nếu phần đầu của nó trùng từng token với request trước (Prompt Caching In Agents). Với một agent gửi lại cả lịch sử mỗi lượt, trúng cache nghĩa là chỉ trả tiền đầy đủ cho phần đuôi mới. pi-ai phơi ra hai nút chung, sessionId và cacheRetention, và mỗi adapter dịch chúng theo cơ chế của hãng.
/** * Resolve cache retention preference. * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility. */function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention { if (cacheRetention) { return cacheRetention; } if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") { return "long"; } return "short";}function getCacheControl( model: Model<"anthropic-messages">, cacheRetention?: CacheRetention, env?: ProviderEnv,): { retention: CacheRetention; cacheControl?: CacheControlEphemeral } { const retention = resolveCacheRetention(cacheRetention, env); if (retention === "none") { return { retention }; } const ttl = retention === "long" && getAnthropicCompat(model).supportsLongCacheRetention ? "1h" : undefined; return { retention, cacheControl: { type: "ephemeral", ...(ttl && { ttl }) }, };}| API | cacheRetention | sessionId |
|---|---|---|
| Anthropic Messages | none: không có cache_control. short: { type: "ephemeral" } (mặc định của Anthropic là 5 phút, theo bài Prompt Caching In Agents). long: thêm ttl: "1h" nếu provider hỗ trợ. | Header x-session-affinity, hoặc x-session-id với OpenRouter, khi bật sendSessionAffinityHeaders. |
| OpenAI Responses | long: prompt_cache_retention: "24h", hoặc prompt_cache_options.ttl: "30m" trên mô hình hỗ trợ chế độ tường minh (packages/ai/src/api/openai-responses.ts:124–141). | prompt_cache_key (cắt còn 64 ký tự) cùng header session_id và x-client-request-id (packages/ai/src/api/openai-responses.ts:320–329). |
| OpenAI Completions | long: prompt_cache_retention: "24h"; với cacheControlFormat: "anthropic" thì gắn cache_control như Anthropic. | prompt_cache_key chỉ gửi cho api.openai.com hoặc khi long được hỗ trợ (packages/ai/src/api/openai-completions.ts:832–837); header affinity theo sessionAffinityFormat. |
Ở Anthropic, cache được điều khiển bằng điểm ngắt: một khối mang cache_control nghĩa là "cache mọi thứ tới đây". pi-ai đặt ba điểm, đều đã thấy ở trên: khối system prompt, tool cuối cùng, khối cuối của message cuối. Điểm thứ ba dịch dần về cuối sau mỗi lượt, nên lượt sau đọc được prefix mà lượt trước đã ghi. Mọi thứ khiến prefix thay đổi đều làm trượt cache: đổi system prompt, thêm bớt tool ở danh sách đầu. Đó là lý do có system message giữa hội thoại và khối tool_addition: chúng cho phép đổi prompt và bộ tool bằng cách nối thêm chứ không sửa phần đầu.
Kết quả đọc được trong usage: cacheRead là token trúng cache, cacheWrite là token được ghi vào cache, cacheWrite1h là phần ghi với thời hạn một giờ mà chỉ Anthropic báo riêng. Ghi một giờ được tính giá gấp đôi input trong calculateCost(). Biến môi trường PI_CACHE_RETENTION=long đổi mặc định cho mọi request không đặt cacheRetention. Thông tin tuổi thọ cache theo mô hình (Model.promptCache) được dùng cho việc làm ấm cache, giải thích ở chương Pi 1.0.
Quyết định thiết kế#
Adapter viết tay cho từng giao thức, không dùng một thư viện trừu tượng chung. Bài viết kể Mario không dùng Vercel AI SDK vì nó không chạy tốt với mô hình tự host, nhất là khi gọi tool. Ở v1.4.0, lý do mạnh hơn nằm ngay trong code: những gì làm agent chạy rẻ và đúng đều là chi tiết riêng của từng hãng. Vị trí điểm ngắt cache của Anthropic, reasoning item mã hoá của OpenAI, mười một cách gửi mức suy nghĩ của các máy chủ tương thích OpenAI, id lời gọi tool 40 hay 64 ký tự. Một lớp trừu tượng chung phải che đi đúng những thứ đó. Cái giá là mỗi adapter dài khoảng một đến hai nghìn dòng và phải được kiểm thử với từng hãng (README liệt kê bộ test bắt buộc cho provider mới, packages/ai/README.md:1941–1958).
Mọi thứ là dữ liệu JSON. Context, message, mô hình, tool, credential đều serialize được. Vì vậy entry message trong session file của coding agent lưu thẳng một AgentMessage, tức Message của pi-ai cộng các vai riêng (session-manager.ts), entry của Pi Durable mang phần cho mô hình dưới dạng một mảng Message (durable/src/types.ts), và đổi mô hình giữa chừng chỉ là gửi cùng một mảng message cho adapter khác. Kiểu IsJsonCompatible trên ToolResultMessage và JsonObject trên ToolCall.arguments biến tính chất này thành lỗi biên dịch thay vì quy ước.
Lỗi là dữ liệu, không phải ngoại lệ. Sau khi luồng đã trả về, không gì được ném ra: lỗi mạng, lỗi máy chủ, huỷ, tràn ngữ cảnh đều thành một AssistantMessage có stopReason và errorMessage. Người gọi có một đường xử lý duy nhất, message lỗi vẫn có usage để tính tiền, và có thể được lưu, hiển thị, phân loại để thử lại. Bài viết của Mario nhấn mạnh việc huỷ: nhiều API hợp nhất bỏ qua nó, còn pi-ai hỗ trợ huỷ từ đầu tới cuối.
Transcript cục bộ là bản chính. Không có trạng thái hội thoại phía máy chủ (store: false), dữ liệu niêm phong chỉ đi kèm khối như một chữ ký mờ, và chỉ được phát lại cho đúng mô hình đã sinh ra nó. Phương án bị loại là để nhà cung cấp giữ hội thoại: request nhỏ hơn, nhưng session không còn mang sang nhà cung cấp khác hay dựng lại từ log được.
Tập provider thay cho registry toàn cục. API toàn cục cũ (registry theo model.api, khoá lấy từ biến môi trường của tiến trình) không cho hai cấu hình cùng sống trong một tiến trình. createModels() cho mỗi host một tập provider, một kho credential và một identity riêng; Pi Durable còn nhận một hàm trả về Models để host thay cấu hình mà không phải mở lại harness. API cũ được giữ ở /compat làm đường chuyển tiếp thay vì bị xoá ngay (packages/ai/CHANGELOG.md:817).
Chỉ nạp thứ được dùng. Lõi không có side effect; mỗi provider một entry point; SDK nằm sau import() động; flow OAuth chỉ chạy trên Node nằm sau một tên module là biến. Một ứng dụng trình duyệt chỉ dùng Anthropic không phải tải SDK của AWS hay Google.
Thay đổi prompt bằng cách nối thêm. Từ 0.86, system prompt và tool sống trong transcript, nên đổi chúng giữa hội thoại là thêm một system message chứ không viết lại phần đầu, và prefix đã cache được giữ nguyên. Đây là nền cho cách Pi 1.0 nạp tool hoãn lại và giữ cache, mô tả ở chương Pi 1.0 và chương Codemode.
Usage là ước lượng tốt nhất. Bài viết của Mario ghi rằng các hãng báo token ở những thời điểm khác nhau, có hãng ngay đầu luồng, có hãng chỉ ở cuối, nên pi-ai theo dõi token và cache theo kiểu best-effort. Adapter Anthropic ghi usage ngay ở message_start chính vì lý do này: request bị huỷ sớm vẫn có số input.
Cạm bẫy và trường hợp biên#
- Không có thử lại mặc định ở tầng HTTP. Chú thích của
maxRetriesnói SDK của OpenAI và Anthropic mặc định thử lại 2 lần (packages/ai/src/types.ts:205–209), nhưng adapter luôn gọi SDK vớimaxRetries: 0vàretryProviderRequest()mặc định 0 lần. Muốn thử lại thì truyềnmaxRetries, hoặc thử lại cả lượt bằngretryAssistantCall(). - Message bị huỷ không được phát lại. Có thể cất nó vào context, nhưng
transformMessages()bỏ nó khi gửi đi, nên "Please continue" không có gì để tiếp tục. - Tham số tool chưa được kiểm tra.
toolcall_endmang tham số đã phân tích nhưng chưa đối chiếu lược đồ, và có thể là{}kèmargumentsError. Luôn gọivalidateToolCall()trước khi chạy. - Gọi thẳng adapter là bỏ qua hai bước. Import
@earendil-works/pi-ai/api/<id>thì phải tự truyềnapiKeyvà tự bọc context bằngnormalizeContext();streamSimple()của adapter còn ném lỗi đồng bộ khi thiếu khoá (packages/ai/src/api/anthropic-messages.ts:966–968). Hợp đồngStreamFunctioncho phép điều này, nhưng nó khác vớiModels, nơi lỗi thiếu khoá cũng thành một sự kiệnerror. inputkhông phải kích thước ngữ cảnh. Nó không gồm token cache. Muốn biết context đã dùng bao nhiêu thì cộng cả bốn loại, nhưcalculateContextTokens()làm (packages/ai/src/utils/estimate.ts:18–20).- Đổi mô hình là mất suy nghĩ. Mô hình mới thấy chữ và lời gọi tool của mô hình cũ nhưng không thấy suy nghĩ của nó, kể cả khi hai mô hình cùng hãng mà khác id.
- OAuth của Anthropic đổi tên tool trên dây. Tool tên
readđược gửi thànhRead; tên trả về được ánh xạ ngược không phân biệt hoa thường theo danh sách tool hiện tại. Hai tool chỉ khác nhau ở chữ hoa, chữ thường sẽ bị nhầm. - Lý do dừng lạ làm request thất bại.
mapStopReason()của Anthropic ném lỗi với lý do chưa biết thay vì đoán (packages/ai/src/api/anthropic-messages.ts:1686–1688): an toàn hơn, nhưng một giá trị mới phía máy chủ có thể làm hỏng mọi request cho tới khi cập nhật. getModels()chỉ trả mô hình chat. Mô hình ảnh và classifier phải đọc bằnggetModelsOfType()hoặcgetAllModels().- Clone không có dữ liệu mô hình.
src/providers/data/bị gitignore; đọc code trong clone sẽ không thấy giá, cửa sổ ngữ cảnh hay cờ compat của từng mô hình cụ thể. - README lệch code ở vài chỗ: API của xAI và Kimi For Coding,
deferredthiếu trong danh sách lý do dừng, ảnh gửi cho mô hình không nhận ảnh bị thay bằng chữ chứ không "silently ignored", và mô tả củarequiresThinkingAsText.
Đọc code ở đâu#
Thứ tự đề nghị: kiểu dữ liệu, rồi luồng, rồi runtime, rồi một adapter, rồi các phần cắt ngang. Đường dẫn tính từ gốc repo.
| # | File | Đọc để hiểu |
|---|---|---|
| 1 | packages/ai/README.md | Quick Start, Providers and Models, Complete Event Reference, Cross-Provider Handoffs, System Messages. Đối chiếu với code vì có vài chỗ đã cũ. |
| 2 | src/types.ts | Toàn bộ data model: Model, Message, khối nội dung, Usage, AssistantMessageEvent, option. |
| 3 | providers/model-schema.ts, providers/compat-schema.ts | Mức suy nghĩ, giá, và mọi cờ compat kèm mô tả. |
| 4 | utils/event-stream.ts, api/lazy.ts | Hàng đợi sự kiện, result(), cách lỗi chuẩn bị thành sự kiện. |
| 5 | utils/transcript.ts | normalizeContext(), phát lại system message, tách tool cho từng kiểu transport. |
| 6 | src/models.ts | Models, applyAuth(), refresh(), createProvider(), calculateCost(), kẹp mức suy nghĩ. |
| 7 | api/anthropic-messages.ts | Một adapter đầy đủ: client, buildParams(), convertMessages(), vòng đọc SSE, streamSimple(). |
| 8 | api/transform-messages.ts | Chuyển giao giữa mô hình, lấp kết quả tool thiếu. |
| 9 | utils/json-parse.ts | Partial JSON và sửa JSON. |
| 10 | api/openai-responses.ts, api/openai-responses-shared.ts | Output item, reasoning mã hoá, store: false. |
| 11 | api/openai-completions.ts | Đọc detectCompat() và getCompat() ở cuối file trước, rồi buildParams() và vòng đọc chunk. |
| 12 | utils/overflow.ts, utils/provider-retry.ts, utils/retry.ts, utils/error-body.ts | Tràn ngữ cảnh, hai tầng thử lại, thông báo lỗi. |
| 13 | auth/resolve.ts, auth/types.ts, auth/helpers.ts, auth/oauth/anthropic.ts | Phân giải xác thực, khoá làm mới OAuth, một flow OAuth hoàn chỉnh. |
| 14 | providers/all.ts, providers/anthropic.ts, providers/github-copilot.ts | Factory provider, catalog tĩnh, provider nhiều API. |
| 15 | utils/identity.ts, src/telemetry.ts | Header danh tính và attribution, schema telemetry. |
| 16 | src/compat.ts, package.json | API toàn cục cũ, entry point và sideEffects. |
| 17 | coding-agent/src/core/remote-catalog-provider.ts | Lớp phủ catalog từ pi.dev dựng trên refreshModels(). |
| 18 | packages/ai/CHANGELOG.md | Các mốc kiến trúc: 0.80.0 (runtime Models), 0.86.0 (transcript và system message), 0.99.0 (mô hình ảnh và classifier), 1.3.0 (bỏ suy nghĩ khi đổi mô hình), 1.4.0 (identity, telemetry). |
Tóm tắt#
- pi-ai biến "một mô hình cộng một hội thoại" thành một luồng sự kiện chuẩn hoá kết thúc bằng
AssistantMessage. Nó không chạy tool, không lưu session. - API là giao thức, provider là nhà cung cấp, mô hình là dữ liệu.
Modelsđịnh tuyến theomodel.provider,Providerđịnh tuyến theomodel.api, adapter nói đúng giao thức của hãng qua SDK chính thức. - Mọi thứ là JSON, và lỗi cũng là dữ liệu: sau khi luồng đã trả về, mọi thất bại thành message có
stopReasonerrorhoặcaborted, giữ phần nội dung đã nhận. - Luồng bắt đầu bằng
start, mỗi khối có*_start/*_delta/*_endghép theocontentIndex, kết thúc bằng đúng mộtdonehoặcerror;partiallà object sống, không phải ảnh chụp. - Tham số tool được phân tích dần bằng partial JSON lúc đang truyền và phân tích nghiêm ngặt lúc kết thúc; kiểm tra lược đồ là việc của người gọi qua
validateToolCall(). transformMessages()làm cho một transcript dùng được với mọi mô hình: bỏ suy nghĩ của mô hình khác, đổi id lời gọi tool, lấp kết quả thiếu, bỏ lượt hỏng. Transcript cục bộ luôn là bản chính.- Prompt caching đi qua
sessionIdvàcacheRetention; Anthropic dùng ba điểm ngắtcache_control, OpenAI dùngprompt_cache_key; kết quả hiện trongcacheRead,cacheWrite,cacheWrite1h. - Runtime
createModels()cùng các entry point./models,./providers/*,./api/*thay API toàn cục cũ, giờ ở./compat; Pi Durable và coding agent đều dựng trên nó.
5pi-ai: provider và mọi cách kết nối#
Vai trò#
Chương pi-ai giải thích lõi: mô hình dữ liệu, giao thức luồng sự kiện, runtime Models và Provider, hai adapter Anthropic và OpenAI Responses. Chương này đi theo chiều rộng. Câu hỏi của nó là: ở v1.4.0, Pi chạm được tới một mô hình bằng những con đường nào, và mỗi con đường được hiện thực ở đâu trong code.
Mỗi request tới một mô hình phải trả lời ba câu hỏi. Ai phục vụ nó: provider (nhà cung cấp), tức một họ endpoint cùng danh mục mô hình. Nói thế nào: API, tức giao thức trên dây mà một module adapter trong src/api/ hiện thực. Bạn là ai: credential, có thể là một khoá API, một token OAuth của gói thuê bao, credential của nền tảng đám mây, hoặc không gì cả với một máy chủ cục bộ. Ba trục này độc lập với nhau, và phần lớn sự đa dạng của Pi đến từ việc ghép chúng theo nhiều cách.
Ở v1.4.0, builtinProviders() dựng 42 provider có sẵn (providers/all.ts), cộng thêm faux cho test. Coding agent đăng ký thêm llama.cpp qua một extension dựng sẵn, và người dùng thêm bao nhiêu provider tuỳ ý qua models.json hoặc extension. Về phía API có mười API chat, một API sinh ảnh và bốn API classifier (types.ts:64).
Trách nhiệm chia theo gói. pi-ai sở hữu provider, adapter của API và việc phân giải xác thực lúc gửi request. Coding agent thêm các nguồn cấu hình của người dùng (auth.json, models.json, cờ --api-key), lớp phủ catalog từ pi.dev và provider do extension đăng ký, tất cả gói trong ModelRuntime (xem thêm mục dịch vụ của chương AgentSession). Pi Durable không tự tạo provider nào: host đưa cho nó một Models đã cấu hình sẵn.
Mô hình tư duy: ba trục provider, API, credential#
Một mô hình trong catalog là dữ liệu thuần. Ba trường của nó nối ba trục lại với nhau: provider chọn provider, api chọn adapter, baseUrl là nơi adapter gửi tới. Credential không nằm trong mô hình; nó được provider phân giải ở từng request rồi đổ vào option.
Ví dụ minh hoạ: một mô hình và ba trục
model = { id: "moonshotai/kimi-k2.6", provider: "openrouter",
api: "openai-completions", baseUrl: "https://openrouter.ai/api/v1", ... }
Models.streamSimple(model, ...)
│ 1. tìm Provider theo model.provider ───────────► openrouterProvider()
│ 2. Provider.auth phân giải credential ─────────► { apiKey } từ OPENROUTER_API_KEY
│ 3. Provider chọn adapter theo model.api ───────► api/openai-completions.ts (nạp lười)
▼
adapter: SDK openai, baseURL = model.baseUrl ──────► POST /chat/completionsCredential đến từ nhiều nguồn, và mỗi nguồn ứng với một cơ chế trong code. Chương này đi qua từng cơ chế theo thứ tự trong bảng dưới.
| Cách kết nối | Ví dụ | Cơ chế chính |
|---|---|---|
| Khoá API | OPENROUTER_API_KEY, auth.json, --api-key | envApiKeyAuth(), CredentialStore, AuthStorage |
| OAuth của gói thuê bao hay tài khoản | Claude Pro/Max, Sign in with ChatGPT, Copilot | OAuthAuth: trình duyệt cộng callback cục bộ, device code, dán mã |
| Credential của nền tảng đám mây | AWS profile, gcloud ADC, workload identity của Anthropic | ApiKeyAuth.resolve() chỉ báo "đã cấu hình", SDK tự lấy credential; Azure dùng khoá cộng endpoint theo resource |
| Gateway, aggregator | OpenRouter, Vercel, Cloudflare, OpenCode, Radius | Provider có catalog của nhiều hãng, header riêng, đôi khi giao thức riêng |
| Binding trong Worker | env.AI của Cloudflare | createAiBindingFetch() thay fetch |
| Máy chủ cục bộ | llama.cpp router, Ollama, LM Studio, vLLM | Extension llama.cpp, hoặc models.json cộng cờ compat |
| Extension | GitLab Duo, giao thức tự viết | pi.registerProvider() |
| Host cung cấp | Pi Durable | createModels() cộng setProvider() do host gọi |
Trong coding agent, một provider mà người dùng thấy là kết quả của nhiều lớp chồng lên nhau. Lớp nào vắng thì lớp dưới được dùng nguyên vẹn:
withRemoteCatalog() lấy catalog mới hơn từ pi.dev; riêng OpenRouter dùng withPersonalizedCatalog() để lọc theo khoá.Provider hoàn chỉnh do pi.registerProvider(provider) đăng ký, thay cho provider dựng sẵn cùng id. llama.cpp đến từ đây.models.jsonbaseUrl, header, compat, khoá; tạo provider hoàn toàn mới.ProviderConfig của extensionstreamSimple tự viết.Từ vựng dùng trong phần còn lại:
- Credential
- Bản ghi lưu cho một provider:
{ type: "api_key", key?, env? }hoặc{ type: "oauth", access, refresh, expires, ... }. Mỗi id provider có tối đa một credential. ModelAuth- Thứ thật sự đi vào một request:
apiKey,headers,baseUrl. Chú thích của kiểu này đặt ra một ranh giới rõ: cái gì không biểu diễn được bằng ba trường đó thì là cấu hình của provider, không phải xác thực. - Credential môi trường (ambient)
- Credential đọc từ biến môi trường hoặc file của máy (AWS profile, gcloud ADC), không bao giờ được chép vào kho credential của Pi.
- Provider env
- Một
Record<string, string>cấu hình theo từng provider, ví dụCLOUDFLARE_ACCOUNT_IDhayAWS_PROFILE. Nó đi kèm credential, và được ưu tiên hơnprocess.env.
/** * Request auth for a single model request. If a value cannot be expressed as * `apiKey`, `headers`, or `baseUrl`, it is provider config, not auth. */export interface ModelAuth { apiKey?: string; headers?: ProviderHeaders; baseUrl?: string;}/** * Stored api-key credential. `env` holds provider-scoped environment/config * values such as Cloudflare account/gateway ids. */export interface ApiKeyCredential { type: "api_key"; key?: string; env?: ProviderEnv;}Một provider được định nghĩa thế nào#
Phần lớn provider là một file mười lăm dòng. Groq là ví dụ điển hình: một id, một tên hiển thị, một baseUrl, một cách xác thực chuẩn, một catalog và một lazy wrapper của API.
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";import { envApiKeyAuth } from "../auth/helpers.ts";import { createProvider, type Provider } from "../models.ts";import { GROQ_MODELS } from "./groq.models.ts";export function groqProvider(): Provider<"openai-completions"> { return createProvider({ id: "groq", name: "Groq", baseUrl: "https://api.groq.com/openai/v1", auth: { apiKey: envApiKeyAuth("Groq API key", ["GROQ_API_KEY"]) }, models: Object.values(GROQ_MODELS), api: openAICompletionsApi(), });}Bốn thứ trong factory có bốn nguồn khác nhau:
modelsđến từgroq.models.ts, một wrapper sinh tự động import./data/groq.jsonrồi làm phẳng theo loại mô hình. File JSON gom mô hình theo API, khoá dạngchat:<id>, để TypeScript suy được API của từng id;flattenModelCatalog()bỏ lớp nhóm và lọc theotype(model-catalog.ts:53). Thư mụcdata/bị gitignore và được sinh lại khi build (chương pi-ai, catalog).authlà mộtProviderAuth:apiKey,oauth, hoặc cả hai.envApiKeyAuth()là cách chuẩn; provider có nguồn credential đặc biệt tự viếtresolve().apilà mộtProviderStreams, ở đây là lazy wrapperopenAICompletionsApi(), nên SDKopenaichỉ được nạp ở request đầu tiên.baseUrlcấp provider gần như chỉ là siêu dữ liệu. Request đi tớimodel.baseUrlcủa từng mô hình;Provider.baseUrlchỉ được chuyển vàoRefreshModelsContext.baseUrlkhi làm mới catalog (models.ts:555), nhờ đó mộtbaseUrlghi đè trongmodels.jsoncũng áp cho request lấy danh sách mô hình của OpenRouter.
Provider phức tạp hơn vẫn chỉ ghép thêm các mảnh. OpenCode Zen nói bốn API, mỗi API được bọc để chèn header định tuyến theo phiên, và có thêm một classifier:
export function opencodeProvider(): Provider<OpenCodeApi> { return createProvider<OpenCodeApi>({ id: "opencode", name: "OpenCode Zen", auth: { apiKey: envApiKeyAuth("OpenCode API key", ["OPENCODE_API_KEY"]) }, models: [...Object.values(OPENCODE_MODELS), ...Object.values(OPENCODE_CLASSIFIER_MODELS)], api: { "anthropic-messages": withOpenCodeSessionHeader(anthropicMessagesApi()), "google-generative-ai": withOpenCodeSessionHeader(googleGenerativeAIApi()), "openai-completions": withOpenCodeSessionHeader(openAICompletionsApi()), "openai-responses": withOpenCodeSessionHeader(openAIResponsesApi()), }, // OpenCode Zen serves TypeSafe's System One protocol at /zen/v1/systemone. classifiers: { "typesafe-system-one": typesafeSystemOneApi() }, });}withOpenCodeSessionHeader() chép sessionId vào header x-opencode-session nếu người gọi chưa đặt (opencode-headers.ts).Mọi factory dựng sẵn và mọi provider tự định nghĩa trong models.json đều đi qua cùng một hàm, createProvider(). Đây là toàn bộ bề mặt mà một provider có thể khai báo:
export interface CreateProviderOptions<TApi extends Api = Api> { id: string; /** Display name. Default: `id`. */ name?: string; baseUrl?: string; headers?: ProviderHeaders; /** Required — every provider has auth semantics, even ambient/keyless ones. */ auth: ProviderAuth; /** * Static baseline models of every type (empty for purely dynamic providers). * Models without `type` are chat models. */ models: readonly ProviderModel<TApi>[]; /** * Fetch a dynamic model overlay of every type. createProvider restores and * publishes it transactionally and drops models of unknown types. */ fetchModels?: (context: RefreshModelsContext) => Promise<readonly ProviderModel<TApi>[]>; /** Credential-specific chat model availability. See `Provider.filterModels`. */ filterModels?: (models: readonly Model<TApi>[], credential: Credential | undefined) => readonly Model<TApi>[]; /** Credential-specific availability across every model type. See `Provider.filterAllModels`. */ filterAllModels?: ( models: readonly ProviderModel<TApi>[], credential: Credential | undefined, ) => readonly ProviderModel<TApi>[]; /** * Chat implementation: a single one for all chat models, or a map keyed by * `model.api` for mixed-API providers. Optional when `images` or * `classifiers` is given. */ api?: ProviderStreams | Partial<Record<TApi, ProviderStreams>>; /** Image-generation implementations keyed by `model.api`. */ images?: Partial<Record<ImageApi, ProviderImages>>; /** Classifier implementations keyed by `model.api`. */ classifiers?: Partial<Record<ClassifierApi, ProviderClassifier>>;Đối chiếu với 42 factory ở v1.4.0, có sáu khuôn mẫu:
| Khuôn mẫu | Provider |
|---|---|
Một API, envApiKeyAuth() | 20 provider tương thích OpenAI Completions (groq, deepseek, together, ...), cùng google, mistral, minimax, minimax-cn, vercel-ai-gateway |
Một API, ApiKeyAuth tự viết | anthropic (Bearer, federation), amazon-bedrock, google-vertex, cloudflare-workers-ai; cloudflare-ai-gateway cũng tự viết nhưng có bảng API |
Thêm OAuth qua lazyOAuth() | anthropic, openai, openai-codex (chỉ OAuth), github-copilot, kimi-coding, meta, xai, openrouter, radius |
Bảng API theo model.api | fireworks, github-copilot, openrouter, opencode, opencode-go, azure, cloudflare-ai-gateway |
Bọc ProviderStreams để biến đổi request trước khi gửi | azureStreams(), cloudflareStreams(), withOpenCodeSessionHeader() |
Viết tay interface Provider | radius (catalog động thay hẳn bản dựng sẵn); llama.cpp ở coding agent |
typesafe là trường hợp riêng: không có api chat, chỉ có classifiers, điều createProvider() cho phép vì nó chỉ đòi ít nhất một trong ba bảng api, images, classifiers (models.ts:1087).
Lược đồ của mô hình và cờ compat
Các trường của mô hình (mức suy nghĩ, giá, giới hạn input) được khai báo bằng TypeBox trong model-schema.ts, đã đi qua ở phần Data model. compat-schema.ts khai báo cờ compat cho từng API. Mỗi API có lược đồ riêng với mặc định và mô tả riêng, nhưng models.json cho phép đặt compat ở cấp provider, nơi chưa biết mô hình nói API nào. Lời giải là một lược đồ hợp (superset):
/** Compatibility schema for each API whose models accept compat. Mirrors the compat type of Model. */export const CompatSchemasByApi = { "openai-completions": OpenAICompletionsCompatSchema, "openai-responses": OpenAIResponsesCompatSchema, "azure-openai-responses": OpenAIResponsesCompatSchema, "openai-codex-responses": OpenAIResponsesCompatSchema, "anthropic-messages": AnthropicMessagesCompatSchema, "bedrock-converse-stream": BedrockCompatSchema, "mistral-conversations": MistralConversationsCompatSchema, "typesafe-system-one": SystemOneCompatSchema, "cloudflare-workers-ai-system-one": SystemOneCompatSchema,} as const;const ProviderCompatProperties = mergeCompatProperties( [...new Set(Object.values(CompatSchemasByApi))].map((schema) => schema.properties), ProviderCompatPropertyOverrides,);export const ProviderCompatSchema = Type.Object(ProviderCompatProperties, { description: "Provider and model compatibility overrides.", additionalProperties: true,});Thuộc tính trùng tên giữa các API (ví dụ supportsDeveloperRole có ở cả Completions lẫn Responses với mặc định khác nhau) phải có một bản ghi đè chung trong ProviderCompatPropertyOverrides; thiếu thì mergeCompatProperties() ném lỗi ngay lúc nạp module (compat-schema.ts:600). Nhờ vậy lược đồ hợp không thể lặng lẽ lấy mặc định của một API này áp cho API khác. additionalProperties: true giữ cho models.json cũ không bị từ chối khi một cờ bị gỡ.
Bản đồ provider ở v1.4.0#
Các bảng dưới đây dựng từ code ở tag v1.4.0, không từ trí nhớ hay từ README: cột API lấy từ lệnh import lazy wrapper trong từng factory, và với provider nhiều API thì từ luật chọn API trong scripts/generate-models.ts; cột xác thực lấy từ auth của factory. Tên id là liên kết tới file factory. Danh sách mô hình cụ thể của từng provider không có trong bản clone, vì src/providers/data/ bị gitignore; tra cứu chúng ở pi.dev/models. Cách chia nhóm là của tài liệu này, code không có khái niệm nhóm.
Hãng làm mô hình, gọi API trực tiếp (11)
| Provider | API | Xác thực | Đáng chú ý |
|---|---|---|---|
anthropic | anthropic-messages | ANTHROPIC_AUTH_TOKEN (gửi dạng Bearer), ANTHROPIC_OAUTH_TOKEN, ANTHROPIC_API_KEY, rồi workload identity federation; OAuth Claude Pro/Max | Token OAuth chuyển adapter sang danh tính Claude Code (chương pi-ai) |
openai | openai-responses; classifier openai-decisions | OPENAI_API_KEY; OAuth Sign in with ChatGPT | Khi đăng nhập OAuth, classifier bị ẩn vì Decisions API từ chối token ChatGPT (openai.ts:27) |
google | google-generative-ai | GEMINI_API_KEY | Adapter từ chối options.fetch tuỳ biến (google-generative-ai.ts:87) |
mistral | mistral-conversations | MISTRAL_API_KEY | Không dùng SDK: adapter tự POST tới v1/chat/completions bằng fetch (mistral-conversations.ts:298); id lời gọi tool 9 ký tự |
xai | openai-responses | XAI_API_KEY; OAuth device code (SuperGrok, X Premium) | README ghi Completions; code dùng Responses |
meta | openai-responses | META_API_KEY; OAuth device code (gói Muse) | Identity token của OAuth không dùng để suy luận được; Pi đổi nó lấy một khoá API sống khoảng một ngày |
deepseek | openai-completions | DEEPSEEK_API_KEY | thinkingFormat: "deepseek", reasoning_content trên mọi assistant message, max_tokens (detectCompat) |
moonshotai | openai-completions | MOONSHOT_API_KEY | api.moonshot.ai; không gửi reasoning_effort, dùng max_tokens |
minimax | anthropic-messages | MINIMAX_API_KEY | Endpoint tương thích Anthropic api.minimax.io/anthropic |
xiaomi | openai-completions | XIAOMI_API_KEY | Endpoint tính tiền theo API; MiMo tự cắt input khi tràn (chương pi-ai) |
ant-ling | openai-completions | ANT_LING_API_KEY | thinkingFormat: "ant-ling" |
Nền tảng suy luận cho mô hình mở (7)
| Provider | API | Xác thực | Đáng chú ý |
|---|---|---|---|
groq | openai-completions | GROQ_API_KEY | api.groq.com/openai/v1 |
cerebras | openai-completions | CEREBRAS_API_KEY | Không gửi store, không dùng vai developer |
together | openai-completions | TOGETHER_API_KEY | thinkingFormat: "together", max_tokens, không có lưu cache dài |
fireworks | anthropic-messages cho đa số; openai-completions cho GLM và Kimi K3 | FIREWORKS_API_KEY | Cache theo session affinity; không nhận cache_control trên tool (generate-models.ts:1721) |
baseten | openai-completions | BASETEN_API_KEY | Mức suy nghĩ qua chat_template_args; header session affinity (CHANGELOG 0.86.0) |
huggingface | openai-completions | HF_TOKEN | Router router.huggingface.co/v1 |
nvidia | openai-completions | NVIDIA_API_KEY | NIM công khai; header ghi nhận X-BILLING-INVOKE-ORIGIN (identity.ts:120) |
Nền tảng đám mây (4)
| Provider | API | Xác thực | Đáng chú ý |
|---|---|---|---|
amazon-bedrock | bedrock-converse-stream | Bearer token, AWS profile, khoá IAM, ECS task role, web identity; không có OAuth | SDK AWS tự chạy chuỗi credential; vùng lấy từ ARN của mô hình (mục đám mây) |
google-vertex | google-vertex | GOOGLE_CLOUD_API_KEY, hoặc ADC cộng project và location | Gemini qua Vertex AI |
azure | azure-openai-responses cho mô hình OpenAI; openai-completions cho mô hình Foundry | AZURE_OPENAI_API_KEY cộng AZURE_OPENAI_BASE_URL hoặc AZURE_OPENAI_RESOURCE_NAME | Mô hình có baseUrl: "", được điền theo từng request; tên deployment ánh xạ được |
cloudflare-workers-ai | openai-completions; classifier cloudflare-workers-ai-system-one | CLOUDFLARE_API_KEY cộng CLOUDFLARE_ACCOUNT_ID | URL chứa placeholder {CLOUDFLARE_ACCOUNT_ID} (mục Cloudflare) |
Gateway và aggregator (6)
| Provider | API | Xác thực | Đáng chú ý |
|---|---|---|---|
openrouter | anthropic-messages cho anthropic/*, openai-completions cho phần còn lại; ảnh openrouter-images; classifier typesafe-system-one | OPENROUTER_API_KEY; OAuth PKCE trả về một khoá vĩnh viễn | Catalog lọc theo khoá qua /models/user; header ghi nhận; x-session-id |
vercel-ai-gateway | anthropic-messages cho mọi mô hình chat; classifier typesafe-system-one | AI_GATEWAY_API_KEY | Mọi hãng đi qua endpoint tương thích Anthropic của gateway |
cloudflare-ai-gateway | openai-responses (OpenAI), anthropic-messages (Anthropic), openai-completions (workers-ai/*) | CLOUDFLARE_API_KEY, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_GATEWAY_ID | Khoá đi trong cf-aig-authorization, header xác thực của SDK bị xoá |
opencode (Zen) | bốn API: anthropic-messages, google-generative-ai, openai-completions, openai-responses; classifier typesafe-system-one | OPENCODE_API_KEY | x-opencode-session theo phiên, x-opencode-client ghi nhận |
opencode-go | bốn API như Zen | OPENCODE_API_KEY (dùng chung) | Endpoint opencode.ai/inference/go |
radius | pi-messages | RADIUS_API_KEY; OAuth trình duyệt hoặc device code | Gateway của Earendil; catalog lấy từ /v1/config thay hẳn bản dựng sẵn |
Truy cập bằng gói thuê bao (2)
Hai provider tồn tại chỉ để dùng một gói thuê bao. Các gói khác (Claude Pro/Max, ChatGPT, SuperGrok, Muse, Kimi Code) là phương thức OAuth gắn thêm vào provider của hãng ở các bảng trên.
| Provider | API | Xác thực | Đáng chú ý |
|---|---|---|---|
github-copilot | anthropic-messages cho Claude, openai-responses cho GPT, Grok, OSWE, MAI, openai-completions cho phần còn lại (generate-models.ts:2338) | COPILOT_GITHUB_TOKEN; OAuth device code, có GitHub Enterprise | baseUrl theo tài khoản lấy từ token; lọc mô hình theo danh sách tài khoản được dùng |
openai-codex | openai-codex-responses | Chỉ OAuth (ChatGPT Plus/Pro): trình duyệt hoặc device code | Tên "OpenAI Codex (legacy)"; chatgpt.com/backend-api; SSE hoặc WebSocket; header chatgpt-account-id |
Gói coding, token plan và biến thể theo vùng (11)
Cùng một hãng thường có nhiều endpoint: một cho tính tiền theo API, một cho gói coding, một cho Trung Quốc đại lục, một cho từng vùng của token plan. Mỗi endpoint là một provider riêng, vì credential và catalog của chúng khác nhau. Tất cả nói openai-completions, trừ minimax-cn nói anthropic-messages.
| Provider | Endpoint | Xác thực | Đáng chú ý |
|---|---|---|---|
kimi-coding | api.kimi.com/coding/v1 | KIMI_API_KEY; OAuth device code (Kimi Code) | Token OAuth đi dưới dạng header Authorization, không phải apiKey |
zai | api.z.ai/api/coding/paas/v4 | ZAI_API_KEY | Coding Plan quốc tế; thinkingFormat: "zai"; có thể tràn ngữ cảnh lặng lẽ |
zai-coding-cn | open.bigmodel.cn/api/coding/paas/v4 | ZAI_CODING_CN_API_KEY | Coding Plan Trung Quốc |
qwen-token-plan | token-plan.ap-southeast-1.maas.aliyuncs.com | QWEN_TOKEN_PLAN_API_KEY | Catalog rộng, giữ để tương thích ngược |
qwen-token-plan-individual | cùng endpoint trên | QWEN_TOKEN_PLAN_API_KEY (dùng chung) | Chỉ mô hình của gói Individual (README) |
qwen-token-plan-cn | token-plan.cn-beijing.maas.aliyuncs.com | QWEN_TOKEN_PLAN_CN_API_KEY | |
xiaomi-token-plan-cn | token-plan-cn.xiaomimimo.com/v1 | XIAOMI_TOKEN_PLAN_CN_API_KEY | |
xiaomi-token-plan-ams | token-plan-ams.xiaomimimo.com/v1 | XIAOMI_TOKEN_PLAN_AMS_API_KEY | Amsterdam |
xiaomi-token-plan-sgp | token-plan-sgp.xiaomimimo.com/v1 | XIAOMI_TOKEN_PLAN_SGP_API_KEY | Singapore |
minimax-cn | api.minimaxi.com/anthropic | MINIMAX_CN_API_KEY | anthropic-messages |
moonshotai-cn | api.moonshot.cn/v1 | MOONSHOT_API_KEY (dùng chung với moonshotai) |
Không phải chat (1), và những provider ngoài danh sách dựng sẵn
| Provider | API | Xác thực | Đáng chú ý |
|---|---|---|---|
typesafe | chỉ classifier typesafe-system-one | TYPESAFE_API_KEY | Jev của TypeSafe; không có api chat |
faux | faux (tự đặt) | không cần: resolve luôn trả { auth: {} } | Phát lại các phản hồi soạn sẵn, giả lập tốc độ token và deferred; dùng trong test, kể cả worker thử nghiệm của Pi Durable |
llama.cpp | openai-completions; classifier llama-cpp-classify, typesafe-system-one | URL máy chủ cộng khoá tuỳ chọn | Extension dựng sẵn của coding agent, không nằm trong pi-ai (mục cục bộ) |
| tự định nghĩa | bất kỳ API nào có sẵn, hoặc API tự viết | tuỳ cấu hình | Qua models.json hoặc pi.registerProvider() |
Đếm lại: 11 cộng 7 cộng 4 cộng 6 cộng 2 cộng 11 cộng 1 là 42, khớp với builtinProviders(). Vài chi tiết dễ bỏ sót: openai-codex là provider duy nhất không có cách xác thực bằng khoá; moonshotai và moonshotai-cn, qwen-token-plan và qwen-token-plan-individual, opencode và opencode-go dùng chung biến môi trường nhưng credential lưu theo id provider, nên một lần /login chỉ cấu hình một provider.
Các API trên dây và lazy loading#
Thư mục packages/ai/src/api/ có 43 file: mười lăm adapter, mỗi adapter cho một API (mười chat, một ảnh, bốn classifier), mười lăm lazy wrapper *.lazy.ts tương ứng, và mười ba helper dùng chung. Ba kiểu API được khai báo riêng: KnownApi cho chat (chương pi-ai), cùng hai kiểu dưới đây.
export type KnownImageApi = "openrouter-images";export type ImageApi = KnownImageApi | (string & {});export type KnownClassifierApi = | "typesafe-system-one" | "cloudflare-workers-ai-system-one" | "llama-cpp-classify" | "openai-decisions";export type ClassifierApi = KnownClassifierApi | (string & {});Bảng các adapter
| Module | Nói gì trên dây | Đi qua | Provider dùng nó |
|---|---|---|---|
anthropic-messages | Anthropic Messages, SSE | @anthropic-ai/sdk (lớp con PiAnthropic) | anthropic, minimax, minimax-cn, vercel-ai-gateway; theo mô hình ở fireworks, github-copilot, openrouter, opencode, opencode-go, cloudflare-ai-gateway |
openai-responses | OpenAI Responses, store: false | SDK openai | openai, xai, meta; theo mô hình ở github-copilot, opencode, opencode-go, cloudflare-ai-gateway |
openai-completions | Chat Completions và mọi phương ngữ "tương thích OpenAI", điều chỉnh bằng cờ compat | SDK openai | 20 provider một API ở bảng trên, cloudflare-workers-ai; theo mô hình ở fireworks, github-copilot, openrouter, opencode, opencode-go, azure, cloudflare-ai-gateway; llama.cpp và máy chủ cục bộ |
openai-codex-responses | Responses của backend ChatGPT ở /codex/responses, qua SSE hoặc WebSocket | fetch và WebSocket; chỉ dùng kiểu của SDK | openai-codex |
azure-openai-responses | Responses trên Azure, tên deployment thay cho id mô hình | AzureOpenAI của SDK openai | azure |
bedrock-converse-stream | Bedrock ConverseStream | @aws-sdk/client-bedrock-runtime, ký SigV4 hoặc Bearer | amazon-bedrock |
google-generative-ai | Gemini API | @google/genai | google; theo mô hình ở opencode, opencode-go |
google-vertex | Gemini trên Vertex AI | @google/genai với vertexai: true | google-vertex |
mistral-conversations | Chat completions theo phương ngữ của Mistral | fetch thuần | mistral |
pi-messages | Giao thức riêng của Pi (bên dưới) | fetch thuần | radius; bất kỳ provider nào trong models.json khai báo "api": "pi-messages" |
openrouter-images | Sinh ảnh qua Chat Completions không stream, với modalities | SDK openai | openrouter |
typesafe-system-one | System One: POST <baseUrl>/systemone | fetch qua classifier-shared.ts | typesafe, openrouter, vercel-ai-gateway, opencode; mô hình quyết định của llama.cpp |
cloudflare-workers-ai-system-one | System One bọc trong phong bì /ai/run của Workers AI | fetch | cloudflare-workers-ai |
openai-decisions | OpenAI Decisions: POST /v1/decisions | fetch | openai (chỉ với khoá API) |
llama-cpp-classify | Dùng mô hình chat làm classifier: /tokenize, /apply-template, /completion với n_probs | fetch | llama.cpp |
Các helper dùng chung, theo người dùng chúng:
openai-responses-shared.ts: đổi message và đọc luồng của họ Responses, dùng chung choopenai-responses,azure-openai-responses,openai-codex-responses.google-shared.ts: phần chung của hai adapter Gemini.system-one-shared.tsvàclassifier-shared.ts: định dạng System One và HTTP của classifier, mỗi dịch vụ chỉ khai báo một "transport" gồm URL, phong bì request và cách bóc output (system-one-shared.ts:56).github-copilot-headers.ts: ba header động mà Copilot đòi,X-Initiator(userhayagenttuỳ message cuối),Openai-IntentvàCopilot-Vision-Requestkhi có ảnh (github-copilot-headers.ts:23); ba adapter Anthropic, Completions, Responses gọi nó khimodel.provider === "github-copilot".openai-prompt-cache.ts: cắtprompt_cache_keycòn 64 ký tự, cho bốn adapter họ OpenAI.constrained-sampling.ts: làm lược đồ JSON thành dạng strict và chuẩn bị tool văn phạm, dùng ở mọi adapter chat trừpi-messages(hai adapter Gemini dùng quagoogle-shared.ts).azure-openai-config.ts: endpoint và deployment của Azure (mục đám mây).cloudflare-ai-binding.ts:fetchqua binding của Worker (mục Cloudflare).cloudflare.tschỉ chứa các hằng URL có placeholder mà bộ sinh catalog dùng.transform-messages.ts(chuyển giao giữa mô hình, chương pi-ai),simple-options.ts(option chung, ngân sách suy nghĩ),lazy.ts(bên dưới).
Lazy loading: bốn cách giữ SDK ngoài bundle
Một ứng dụng trình duyệt chỉ dùng Anthropic không nên phải tải SDK của AWS. pi-ai giữ điều này bằng cách để factory chỉ import một lazy wrapper, còn module adapter (và SDK của nó) chỉ được import() ở request đầu tiên. Có bốn biến thể, tuỳ việc module có chạy được trong trình duyệt hay không:
- Adapter chat bình thường
lazyApi(() => import("./anthropic-messages.ts")). Specifier là chuỗi hằng nên bundler tách được thành một chunk lười;lazyStream()biến lỗi nạp thành sự kiệnerror(chương pi-ai). - Adapter chỉ chạy trên NodeBedrock nạp qua một specifier là biến, để bundler (bản thử trình duyệt, bản biên dịch Bun) không đi theo được vào AWS SDK; bản Bun đăng ký module tĩnh qua
setBedrockProviderModule()(excerpt dưới). - API một lần (ảnh, classifier)Không có luồng nên không cần
lazyStream(): wrapper là một object có phương thứcawait import(...)rồi gọi tiếp (typesafe-system-one.lazy.ts). - Flow OAuth
lazyOAuth()nhận một hàmload;load.tscũng dùng specifier là biến vì flow cầnnode:http,node:crypto, và bản Bun đăng ký flow tĩnh quaregisterBundledOAuthFlowLoaders()(bun-oauth.ts).
/** * Loads the bedrock implementation through a variable specifier so bundlers * (browser smoke, Bun compile) cannot follow the import into the Node-only * AWS SDK. The `.ts`/`.js` rewrite keeps the trick working from both source * and built output. */const importNodeOnlyApi = (specifier: string): Promise<unknown> => { const runtimeSpecifier = import.meta.url.endsWith(".js") ? specifier.replace(/\.ts$/, ".js") : specifier; return import(runtimeSpecifier);};let bedrockModuleOverride: ProviderStreams | undefined;/** * Overrides the dynamically imported bedrock implementation. Used by the Bun * binary build, where the variable-specifier import cannot be bundled; the * build registers a statically imported module instead. */export function setBedrockProviderModule(module: ProviderStreams): void { bedrockModuleOverride = module;}export const bedrockConverseStreamApi = (): ProviderStreams => lazyApi( async () => bedrockModuleOverride ?? ((await importNodeOnlyApi("./bedrock-converse-stream.ts")) as ProviderStreams), );Mẹo đổi đuôi .ts thành .js theo import.meta.url giúp cùng một dòng chạy được cả khi chạy thẳng mã nguồn TypeScript lẫn khi chạy bản build. Cái giá của specifier là biến là bundler không biết module đó tồn tại, nên bản phân phối nào đóng gói đơn file phải tự đăng ký nó, đúng như entry ./bedrock-provider và ./bun-oauth trong package.json làm.
pi-messages: giao thức riêng của Pi
Mọi adapter khác dịch transcript của Pi sang định dạng của một hãng, rồi dịch luồng của hãng về sự kiện của Pi. pi-messages bỏ cả hai bước dịch: client gửi chính transcript đã chuẩn hoá và nhận lại chính các sự kiện của pi-ai, đã serialize. Việc nói chuyện với nhà cung cấp thật nằm ở máy chủ. Đó là giao thức của gateway Radius, nhưng chú thích đầu file nói rõ mọi backend hiện thực nó đều dùng được (pi-messages.ts:1).
const url = new URL(`${model.baseUrl.replace(/\/+$/u, "")}/messages`);if (options?.debug) { url.searchParams.set("debug", "1");}let payload: unknown = { model: model.id, context, options: { temperature: options?.temperature, maxTokens: options?.maxTokens, reasoning: options?.reasoning, cacheRetention: resolveCacheRetention(options?.cacheRetention, options?.env), sessionId: options?.sessionId, toolChoice: options?.toolChoice, },};const nextPayload = await options?.onPayload?.(payload, model);if (nextPayload !== undefined) { payload = nextPayload;}const response = await (options?.fetch ?? globalThis.fetch)(url, { method: "POST", headers: providerHeadersToRecord( getIdentityHeaders(model, options?.identity), { authorization: `Bearer ${apiKey}`, accept: "text/event-stream", "content-type": "application/json", }, options?.headers, ), body: JSON.stringify(payload), signal: options?.signal,});Request là một POST tới <baseUrl>/messages. context là TranscriptContext nguyên vẹn, tức system prompt và tool nằm trong các system message; options chỉ gồm option thống nhất của Pi (reasoning, cacheRetention, sessionId, toolChoice), không có tham số riêng của hãng nào. Header identity được gộp dưới header xác thực và header của người gọi, như mọi adapter. Phản hồi là một luồng SSE mà mỗi bản tin là một sự kiện:
/** Serialized assistant-message event as sent by a pi-messages backend. */export type PiMessagesEvent = | { type: "start" } | { type: "text_start"; contentIndex: number } | { type: "text_delta"; contentIndex: number; delta: string } | { type: "text_end"; contentIndex: number; content: string; contentSignature?: string } | { type: "thinking_start"; contentIndex: number } | { type: "thinking_delta"; contentIndex: number; delta: string } | { type: "thinking_end"; contentIndex: number; content: string; contentSignature?: string; redacted?: boolean; } | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string } | { type: "toolcall_delta"; contentIndex: number; delta: string } | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall } | { type: "done"; reason: Extract<PiMessagesStopReason, "stop" | "length" | "toolUse">; usage: PiMessagesUsage; responseId?: string; providerThinkingLevel?: string; rewrite?: PiMessagesRewriteImpact; } | { type: "error"; reason: Extract<PiMessagesStopReason, "aborted" | "error">; usage: PiMessagesUsage; errorMessage?: string; responseId?: string; providerThinkingLevel?: string; rewrite?: PiMessagesRewriteImpact; };Đây là bộ sự kiện của giao thức luồng, bỏ trường partial. Client tự dựng lại message dở bằng một bộ chuyển đổi nhỏ: delta chữ và suy nghĩ được nối vào khối theo contentIndex, mảnh JSON của tham số tool được phân tích dần bằng parseStreamingJson(), toolcall_end mang lời gọi tool đã chốt từ máy chủ (createEventConverter). Có ba khác biệt đáng nhớ so với adapter thường:
- Usage và chi phí đến nguyên từ sự kiện
donehayerrorcủa máy chủ; client không gọicalculateCost(), nên giá là giá gateway tính. - Sự kiện cuối có thể mang
rewrite: tóm tắt việc một chính sách của gateway đã sửa message (số token, số message thay đổi, system prompt có bị đổi không). Client ghi nó thành một diagnosticpi_messages_rewritetrên message, để người dùng biết request đã bị sửa phía máy chủ (pi-messages.ts:44). - Không có SDK nào, nên module này nhỏ (448 dòng) và chạy ở mọi môi trường có
fetch. Luồng hết mà không có sự kiện cuối là lỗi.
Credential trong pi-ai: môi trường, kho và thứ tự#
pi-ai biết ba nguồn credential: giá trị tường minh trong option của request, kho credential (CredentialStore) mà ứng dụng tiêm vào createModels(), và môi trường xung quanh, đọc qua một AuthContext. Thứ tự giữa chúng do resolveProviderAuth() quyết định, đã trích ở chương pi-ai; tóm lại:
options.apiKeyNếu có, nó được bọc thành một credentialapi_keytạm rồi vẫn đi quaresolve()của provider. Đi đường vòng này là có chủ ý: một provider như Cloudflare cần dựng URL theo account từ cùng lần phân giải, kể cả khi khoá đến từ lời gọi (CHANGELOG 0.80.2).- Credential đã lưuOAuth thì làm mới nếu còn dưới năm phút rồi gọi
toAuth();api_keythì đưa vàoresolve(). Credential đã lưu "sở hữu" provider: lưu OAuth mà provider không cóoauth, hoặc làm mới thất bại, thì không có đường lùi về biến môi trường. - Môi trườngKhông có gì được lưu thì
resolve()chạy vớicredential: undefinedvà tự đọc biến môi trường, file, profile. Kết quảundefinednghĩa là provider chưa được cấu hình.
options.env phủ lên môi trường của tiến trình cho riêng request đó. Cơ chế chỉ là một AuthContext bọc ngoài:
function overlayEnvAuthContext(base: AuthContext, env: ProviderEnv): AuthContext { return { env: async (name) => env[name] || (await base.env(name)), fileExists: (path) => base.fileExists(path), };}Bản thân AuthContext chỉ có hai thao tác bất đồng bộ, env(name) và fileExists(path) (auth/types.ts:96). Bản mặc định đọc process.env, coi chuỗi rỗng hoặc toàn khoảng trắng là không đặt, và kiểm tra file bằng node:fs với ~ được mở rộng. Trong trình duyệt, env() luôn trả undefined và fileExists() luôn false, nên ở đó chỉ còn khoá truyền tường minh hoặc kho credential.
export function defaultProviderAuthContext(): AuthContext { return { async env(name: string): Promise<string | undefined> { const value = getProcessEnv()?.[name]; return typeof value === "string" && value.trim().length > 0 ? value : undefined; }, async fileExists(path: string): Promise<boolean> { try { const fs = (await importNodeModule("node:fs/promises")) as NodeFsModule; let resolved = path; if (resolved.startsWith("~")) { const os = (await importNodeModule("node:os")) as NodeOsModule; resolved = os.homedir() + resolved.slice(1); } await fs.access(resolved); return true; } catch { return false; } }, };}Vì AuthContext tiêm được, một host chạy nhiều người dùng trong một tiến trình có thể cho mỗi Models một nguồn biến môi trường riêng, và test không phải sửa process.env. Adapter thì đọc cấu hình không phải xác thực (vùng của AWS, phiên bản API của Azure, PI_CACHE_RETENTION) qua một hàm khác, getProviderEnvValue(): options.env trước, rồi process.env, rồi một đường lùi đọc /proc/self/environ cho bản biên dịch Bun, nơi process.env có thể rỗng trong sandbox Linux (provider-env.ts).
Kiểm tra mà không phân giải
Models cần biết provider nào "có credential" để liệt kê mô hình dùng được (getAvailable()), và việc đó chạy thường xuyên. checkProviderAuth() coi một OAuth đã lưu là đủ, không làm mới; với khoá, nó dùng ApiKeyAuth.check() nếu provider có, không thì phân giải thật (models.ts:664). check() tồn tại cho những provider mà resolve() có tác dụng phụ: models.json có thể khai báo khoá là một lệnh shell, và kiểm tra không được chạy lệnh đó (auth/types.ts:177).
env-api-keys.ts ở v1.4.0
Tên biến môi trường của từng provider giờ nằm trong factory của nó, trong envApiKeyAuth("...", ["GROQ_API_KEY"]). File env-api-keys.ts giữ một bảng song song từ thời API toàn cục (env-api-keys.ts:73), và ở v1.4.0 chỉ còn đường /compat gọi getEnvApiKey() (compat.ts:224). Hai bảng khớp nhau cho mọi provider; đáng chú ý là với Vertex và Bedrock, bảng cũ trả về chuỗi đánh dấu "<authenticated>" khi chỉ có credential môi trường, và compat bỏ qua đúng chuỗi này thay vì gửi nó đi như một khoá. Cách mới không cần mẹo đó: resolve() trả { auth: {} }, nghĩa là "đã cấu hình, nhưng không có gì để chèn vào request".
Credential trong coding agent: auth.json, --api-key, pi auth#
pi-ai mặc định giữ credential trong bộ nhớ. Coding agent tiêm vào createModels() một kho bền là AuthStorage, đọc ghi file <agent-dir>/auth.json (mặc định ~/.pi/agent/auth.json), rồi bọc nó trong RuntimeCredentials để chứa khoá của cờ --api-key (model-runtime.ts:219). File có dạng một object, khoá là id provider:
Ví dụ minh hoạ: auth.json (giá trị bí mật thay bằng …)
{
"anthropic": { "type": "oauth", "access": "…", "refresh": "…", "expires": 1761100000000 },
"openrouter": { "type": "api_key", "key": "!pass show openrouter" },
"cloudflare-ai-gateway": {
"type": "api_key", "key": "…",
"env": { "CLOUDFLARE_ACCOUNT_ID": "…", "CLOUDFLARE_GATEWAY_ID": "…" }
}
}key bắt đầu bằng ! là một lệnh, chạy khi cần lần đầu; env là cấu hình theo provider, được ưu tiên hơn biến môi trường của tiến trình.Ba chi tiết của AuthStorage làm file này an toàn khi nhiều tiến trình Pi chạy cùng lúc. File được tạo với quyền 0600, thư mục cha 0700 (auth-storage.ts:36). Mọi lần ghi đi qua một khoá file (proper-lockfile, coi là cũ sau 30 giây), và modify() đọc lại file bên trong khoá trước khi gộp, nên một /login ở tiến trình khác không bị ghi đè. Mỗi lần đọc so sánh "revision" của file với lần trước và đọc lại khi khác, nên một tiến trình thấy ngay token do tiến trình khác vừa làm mới.
async read(provider: string, options?: AuthOperationOptions): Promise<Credential | undefined> { const credential = (await this.readLatestData(options))[provider]; options?.signal?.throwIfAborted(); if (credential?.type !== "api_key") return credential; if (credential.key === undefined) return credential; return { ...credential, key: resolveConfigValue(credential.key, credential.env) };}async modify( provider: string, fn: (current: Credential | undefined) => Promise<Credential | undefined>, options?: AuthOperationOptions,): Promise<Credential | undefined> { let latestData = this.readState.data; let revision: string | undefined; const result = await this.storage.withLockAsync(async (content) => { const currentData = this.parseStorageData(content); const next = await fn(currentData[provider]); if (next === undefined) { latestData = currentData; revision = this.authPath ? getFileRevision(this.authPath) : undefined; return { result: currentData[provider] }; } const merged: AuthStorageData = { ...currentData, [provider]: next }; latestData = merged; return { result: next, next: JSON.stringify(merged, null, 2) }; }, options); this.updateReadState(latestData, revision); return result;}read() đưa key qua resolveConfigValue(), nên trong auth.json có thể viết !lệnh hoặc $TÊN_BIẾN, với biến lấy từ env của chính credential trước rồi mới tới process.env. Kết quả của lệnh được cache suốt đời tiến trình; lệnh lỗi, hết 10 giây hay in ra chuỗi rỗng thì khoá chưa được phân giải cho tới khi khởi động lại (docs/providers.md:71, resolve-config-value.ts:185). Đây là cách dùng một trình quản lý bí mật mà không ghi khoá ra đĩa.
--api-key: một lớp phủ không bền
/** Async credential store overlay for non-persistent runtime API keys. */export class RuntimeCredentials implements CredentialStore { private readonly store: CredentialStore; private readonly overrides = new Map<string, string>(); constructor(store: CredentialStore) { this.store = store; } setRuntimeApiKey(providerId: string, apiKey: string): void { this.overrides.set(providerId, apiKey); } removeRuntimeApiKey(providerId: string): void { this.overrides.delete(providerId); } hasRuntimeApiKey(providerId: string): boolean { return this.overrides.has(providerId); } async read(providerId: string, options?: AuthOperationOptions): Promise<Credential | undefined> { options?.signal?.throwIfAborted(); const override = this.overrides.get(providerId); return override ? { type: "api_key", key: override } : this.store.read(providerId, options); }main.ts gắn khoá của --api-key cho đúng provider của mô hình đã chọn, sau khi phân giải --model (main.ts:831). Vì lớp phủ nằm ở read() của kho, pi-ai thấy nó như một credential đã lưu, và nó thắng mọi thứ khác. Nó không bao giờ được ghi xuống file.
Thứ tự đầy đủ trong coding agent
Khi models.json hoặc một extension cấu hình provider, composeApiKeyAuth() trong provider-composer.ts bọc ApiKeyAuth của provider dựng sẵn (gọi là "inherited") và thêm khoá cấu hình vào giữa chuỗi:
resolve: async (input) => { let result: AuthResult | undefined; if (input.credential) { result = inherited ? await inherited.resolve(input) : input.credential.key ? { auth: { apiKey: input.credential.key }, env: input.credential.env, source: "stored credential" } : undefined; } else if (rawKey !== undefined) { const env = await configContextEnv([rawKey], input.ctx); const key = resolveConfigValueOrThrow(rawKey, `API key for provider "${providerId}"`, env); result = inherited ? await inherited.resolve({ ...input, credential: { type: "api_key", key } }) : { auth: { apiKey: key }, source: "configured API key" }; } else { result = await inherited?.resolve(input); } if (!result) return undefined; const explicitEnv = { ...(input.credential?.env ?? {}), ...(result.env ?? {}) }; const headerEnv = await configContextEnv(Object.values(rawHeaders ?? {}), input.ctx, explicitEnv); const headers = resolveHeadersOrThrow(rawHeaders, `provider "${providerId}"`, headerEnv); return { ...result, auth: withConfiguredAuth(result.auth, headers, authHeader) }; },};Ghép với lớp --api-key và với resolveProviderAuth() của pi-ai, ta có đúng thứ tự mà tài liệu mô tả: --api-key, rồi auth.json, rồi apiKey trong models.json, cuối cùng là biến môi trường hoặc credential môi trường của provider (docs/models.md:23). Hai chi tiết đáng để ý. Khoá cấu hình không thay resolve() dựng sẵn mà được đưa vào nó như một credential, nên Cloudflare vẫn thêm account id, Anthropic vẫn xử lý khoá như thường. Và header cấu hình được phân giải sau cùng, với biến môi trường lấy cả từ env mà provider vừa trả về; authHeader: true thêm Authorization: Bearer <khoá> và báo lỗi nếu không có khoá (withConfiguredAuth). Header khai báo trên từng mô hình được ModelRuntime.getAuth() gộp thêm ở bước cuối (model-runtime.ts:557).
pi auth: xuất credential cho client bên ngoài
Từ 0.83.0, pi auth print-api-key và pi auth print-bearer-token in ra credential đã cấu hình, để một công cụ khác (một script, một client HTTP) dùng chung đăng nhập của Pi; pi auth check, thêm ở 0.84.1, chỉ báo ready, not_ready hay invalid qua mã thoát (docs/cli.md:293). Cơ chế cố ý dùng lại đường phân giải của request:
const credentials: Array<{ providerId: string; value: string }> = [];for (const provider of providers) { const type = credentialTypes.get(provider.id); if (kind === "api_key" && type === "oauth") continue; if (kind === "bearer_token" && type !== "oauth") continue; const authOptions = { ...(kind === "bearer_token" ? { minOAuthValidityMs: minExpiryMs ?? DEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS } : {}), signal, }; const auth = provider.model ? await modelRuntime.getAuth(provider.model, authOptions) : await modelRuntime.getAuth(provider.id, authOptions); const value = getAuthCredential(auth); if (value) credentials.push({ providerId: provider.id, value });}print-api-key bỏ qua provider đăng nhập bằng OAuth, print-bearer-token chỉ nhận OAuth. Token OAuth còn dưới 30 phút (hoặc dưới giá trị của --min-expiry) được làm mới trước khi in, qua minOAuthValidityMs của resolveProviderAuth(); nếu sau khi làm mới token vẫn sắp hết hạn, lệnh báo lỗi thay vì in ra một token sắp chết. getAuthCredential() lấy apiKey, hoặc phần sau Bearer của header Authorization với những provider xác thực bằng header như Kimi Code (auth-command.ts:120). Lệnh ghi bí mật ra stdout, nên đừng chạy nó trong một phiên được ghi log.
Đăng nhập OAuth và gói thuê bao#
Một gói thuê bao (Claude Pro/Max, ChatGPT, Copilot, SuperGrok) không đi kèm khoá API. Muốn dùng nó từ một chương trình, chương trình phải đăng nhập như ứng dụng chính thức của hãng làm, lấy một token, và làm mới token đó khi hết hạn. pi-ai mô tả mọi flow như vậy bằng cùng một interface OAuthAuth với ba thao tác: login(), refresh() và toAuth() (auth/types.ts:216). login() không biết gì về giao diện: nó chỉ gọi prompt() (bốn loại: text, secret, select, manual_code) và notify() (bốn sự kiện: info, auth_url, device_code, progress), nên cùng một flow chạy được trong TUI, trong CLI nhỏ của pi-ai, hay trong một ứng dụng web (auth/types.ts:119).
Chín flow ở v1.4.0, dùng ba kiểu đăng nhập: trình duyệt với máy chủ callback cục bộ, device code (RFC 8628: máy hiện một mã, người dùng nhập mã đó trên một trang web), và dán mã hay URL thủ công.
| Flow | Cách đăng nhập | Credential lưu | refresh() và toAuth() |
|---|---|---|---|
| Anthropic (Claude Pro/Max) | Chọn: trình duyệt cộng PKCE, callback ở cổng 53692 (lùi về cổng trống, rồi về dán URL); hoặc "copy code" cho máy không có trình duyệt | access, refresh, hạn trừ trước năm phút | Grant refresh_token; toAuth trả access token như khoá API |
| OpenAI (Sign in with ChatGPT) | PKCE, đăng ký client động: gửi dynamic_agent_client, nhận client id thật trong callback; cổng cố định 1455 hoặc dán URL | Thêm clientId, scopes; hạn trừ ba phút | Refresh bằng client id đã cấp; token đi thẳng tới api.openai.com |
| OpenAI Codex (legacy) | Chọn: trình duyệt cộng PKCE (cổng 1455, dùng chung với Codex CLI) hoặc device code ở auth.openai.com/codex/device | Thêm accountId đọc từ JWT | Grant refresh_token; adapter tự đọc lại account id từ token |
| GitHub Copilot | Device code ở github.com hoặc miền GitHub Enterprise | refresh là token GitHub, access là token Copilot; thêm availableModelIds | "Refresh" là đổi lại token GitHub lấy token Copilot rồi tải lại danh sách mô hình; toAuth trả cả baseUrl |
| Kimi Code | Device code ở auth.kimi.com (đổi được bằng KIMI_CODE_OAUTH_HOST) | access, refresh | Thử lại tới ba lần khi 429 hay 5xx; toAuth trả header Authorization |
| Meta (Muse) | Device code ở auth.meta.com, rồi đổi identity token lấy một khoá Model API | refresh là identity token, access là khoá sống 24 giờ | "Refresh" là xin khoá mới; 401 hay 403 nghĩa là phải đăng nhập lại |
| xAI (SuperGrok, X Premium) | Device code ở auth.x.ai | access, refresh, hạn trừ năm phút | Giữ refresh token cũ nếu máy chủ không xoay vòng nó |
| OpenRouter | PKCE, callback ở cổng ngẫu nhiên với đường dẫn ngẫu nhiên, hoặc dán URL; hết hạn sau năm phút chờ | Một khoá API vĩnh viễn trong access, expires: Number.MAX_SAFE_INTEGER | refresh trả nguyên credential |
| Radius | Chọn: trình duyệt cộng PKCE (cổng 1456, endpoint ủy quyền tra ở /v1/oauth của gateway) hoặc device code | access, refresh, hạn trừ 60 giây | Grant refresh_token tới chính gateway |
Ba khối dựng chung
PKCE. generatePKCE() sinh 32 byte ngẫu nhiên bằng Web Crypto làm verifier và băm SHA-256 thành challenge, nên chạy được cả trên Node lẫn trình duyệt (pkce.ts:21). Anthropic dùng luôn verifier làm tham số state; OpenRouter không gửi state, nên flow của nó đặt callback ở một đường dẫn chứa UUID ngẫu nhiên để một request lạc không hoàn tất được việc đăng nhập (openrouter.ts:115).
Máy chủ callback. startOAuthCallbackServer() mở một máy chủ HTTP trên loopback, chỉ nhận đúng đường dẫn và đúng state, chỉ xử lý callback đầu tiên, và đổi mã lấy token trước khi trả trang kết quả, để lỗi đổi mã hiện được trên trình duyệt (callback-server.ts:50). Khi trình duyệt chạy trên máy khác (qua SSH, trong container), callback không bao giờ tới; vì thế mọi flow trình duyệt đua callback với một lời nhắc dán mã:
export async function waitForCallbackOrManualInput<T>( interaction: ProviderAuthInteraction, callback: OAuthCallbackServer<T> | undefined, prompt: { message: string; placeholder: string },): Promise<{ type: "callback"; value: T } | { type: "manual"; input: string }> { const manualAbort = new AbortController(); let manualError: Error | undefined; const manual = interaction .prompt({ type: "manual_code", ...prompt, signal: manualAbort.signal }) .then((input) => { callback?.cancel(); return input; }) .catch((error: unknown) => { manualError = error instanceof Error ? error : new Error(String(error)); callback?.cancel(); return undefined; }); try { const value = await callback?.wait(); if (manualError) throw manualError; if (value !== undefined) return { type: "callback", value }; const input = await manual; if (manualError) throw manualError; return { type: "manual", input: input ?? "" }; } finally { manualAbort.abort(); }}Địa chỉ lắng nghe mặc định là 127.0.0.1; biến PI_OAUTH_CALLBACK_HOST đổi được nó cho các flow Anthropic, ChatGPT, Codex và OpenRouter (anthropic.ts:17), còn Radius cố định ở 127.0.0.1:1456. Cổng cố định có lý do: Anthropic chọn 53692 để người dùng chuyển tiếp được cổng vào container hay qua SSH, và lùi về cổng trống vì Anthropic nhận mọi cổng loopback; Sign in with ChatGPT thì không lùi được, và báo lỗi rõ khi cổng 1455 đang bị một phiên Pi khác hay Codex CLI giữ, thay vì để callback rơi vào tiến trình kia rồi hỏng vì sai state (openai-chatgpt.ts:241).
Device code. Bốn flow chỉ dùng device code (Copilot, Kimi, Meta, xAI), hai flow có nó như một lựa chọn (Codex, Radius). Phần hỏi vòng dùng chung một hàm:
while (Date.now() < deadline) { if (options.signal.aborted) { throw new Error(CANCEL_MESSAGE); } const result = await options.poll(); if (result.status === "complete") { return result.value; } if (result.status === "failed") { throw new Error(result.message); } if (result.status === "slow_down") { slowDownResponses += 1; // Use the server-provided interval when given (GitHub reports the new required minimum // in `interval`); trusting only a client-tracked value risks polling early forever under // WSL/VM clock drift. Otherwise apply RFC 8628 section 3.5: increase by 5 seconds. intervalMs = typeof result.intervalSeconds === "number" && Number.isFinite(result.intervalSeconds) && result.intervalSeconds > 0 ? Math.max(MINIMUM_INTERVAL_MS, Math.floor(result.intervalSeconds * 1000)) : Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS); } const remainingMs = deadline - Date.now(); if (remainingMs <= 0) { break; } await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE);}throw new Error(slowDownResponses > 0 ? SLOW_DOWN_TIMEOUT_MESSAGE : TIMEOUT_MESSAGE);Mặc định hỏi năm giây một lần, tối thiểu một giây. Khi máy chủ trả slow_down, khoảng cách lấy theo giá trị máy chủ gửi nếu có, không thì cộng năm giây như RFC quy định. Chú thích trong code giải thích vì sao tin máy chủ: trên WSL hay máy ảo bị lệch đồng hồ, một khoảng cách chỉ do client tự tính có thể hỏi sớm mãi mãi; lỗi hết giờ sau nhiều lần slow_down vì thế gợi ý đồng bộ lại đồng hồ. Với Copilot, Kimi, Meta và xAI, URL xác minh mà máy chủ gửi về được kiểm tra là http(s) (riêng xAI đòi https) trước khi đưa cho trình mở URL, để một phản hồi độc không khiến open chạy một file thực thi (github-copilot.ts:242).
Khi "refresh" không phải là refresh
Interface tách refresh() (tạo credential mới) khỏi toAuth() (suy ra xác thực của request), và sự tách bạch đó đủ rộng để chứa những thứ không phải OAuth chuẩn. Copilot là ví dụ rõ nhất: token GitHub nằm ở trường refresh, "làm mới" là đổi nó lấy một token Copilot ngắn hạn, và toAuth() đọc endpoint của tài khoản ngay trong token:
/** * Parse the proxy-ep from a Copilot token and convert to API base URL. * Token format: tid=...;exp=...;proxy-ep=proxy.individual.githubcopilot.com;... * Returns API URL like https://api.individual.githubcopilot.com */function getBaseUrlFromToken(token: string): string | null { const match = token.match(/proxy-ep=([^;]+)/); if (!match) return null; const proxyHost = match[1]; // Convert proxy.xxx to api.xxx const apiHost = proxyHost.replace(/^proxy\./, "api."); return `https://${apiHost}`;}export const githubCopilotOAuth: OAuthAuth = { name: "GitHub Copilot", isSubscription: true, login: loginGitHubCopilot, refresh: (credential, signal) => refreshGitHubCopilotToken(credential.refresh, copilotEnterpriseDomain(credential), signal), /** Derive the credential-specific proxy endpoint for each request. */ async toAuth(credential) { return { apiKey: credential.access, baseUrl: getGitHubCopilotBaseUrl(credential.access, copilotEnterpriseDomain(credential)), }; },};Lúc đăng nhập, flow Copilot còn gọi /models để biết tài khoản được dùng mô hình nào, và bật chính sách cho những mô hình còn ở trạng thái "unconfigured" (một số mô hình như Claude, Grok phải bật trước khi dùng); filterModels của provider sau đó chỉ cho hiện các id đó (loginGitHubCopilot). Meta đi xa hơn: identity token của nó không dùng để suy luận được và cũng không làm mới được, nên Pi lưu nó vào refresh và "làm mới" bằng cách xin một khoá API mới mỗi ngày; chú thích đầu file nói rõ cách này để bộ lập lịch OAuth chuẩn tự xin lại khoá mà không cần cơ chế riêng (meta.ts:1). OpenRouter thì ngược lại: OAuth chỉ để tạo một khoá API do người dùng kiểm soát, nên không có gì để làm mới.
Sign in with ChatGPT có hai đòi hỏi riêng. Nó cần một id cài đặt ổn định dạng UUID, gửi đi như ext_agent_host_id; coding agent đưa vào qua LoginOptions.getDeviceId, lấy từ settings (interactive-mode.ts:6296). Và grant phải có scope chatgpt.tokens.use.direct, nếu không credential bị từ chối ngay khi đăng nhập (openai-chatgpt.ts:162).
Làm mới an toàn
Việc làm mới chạy bên trong CredentialStore.modify(), với điều kiện hết hạn được kiểm tra lại sau khi đã giữ khoá, và một lần làm mới đã bắt đầu sẽ chạy tới cùng dù người gọi huỷ; cơ chế này đã được giải thích ở chương pi-ai. Changelog cho thấy nó được dựng dần qua các sự cố thật: khoá với kiểm tra kép từ 0.80.0 (CHANGELOG 0.80.0), làm mới khi còn dưới năm phút thay vì đợi hết hạn từ 0.83.0 (CHANGELOG 0.83.0), giới hạn thời gian làm mới để request treo không giữ khoá mãi từ 0.84.0 (CHANGELOG 0.84.0), và từ 1.0.3 một lần làm mới bị huỷ giữa chừng không còn vứt mất refresh token vừa xoay vòng, lỗi từng khiến Sign in with ChatGPT báo refresh_token_invalidated (CHANGELOG 1.0.3, coding-agent CHANGELOG).
Lưu ý thêm: nhiều flow đã tự trừ một khoảng an toàn vào expires lúc đăng nhập (năm phút với Anthropic, xAI, Copilot; ba phút với ChatGPT; 60 giây với Radius), rồi resolveStoredOAuth() lại làm mới khi còn dưới năm phút. Hai khoảng an toàn cộng dồn, nên token thực tế được làm mới sớm hơn hạn thật từ sáu (Radius) tới mười phút (Anthropic, xAI, Copilot).
Nền tảng đám mây: Bedrock, Vertex, Azure, workload identity#
Nền tảng đám mây không cấp "một khoá API" theo nghĩa thông thường. Credential của chúng là một chuỗi nguồn (profile, khoá IAM, vai trò của container, file tài khoản dịch vụ) mà SDK chính thức đã biết cách đi qua. pi-ai không lặp lại chuỗi đó. Với Bedrock và Vertex, resolve() của provider chỉ làm hai việc: báo provider "đã được cấu hình" để mô hình hiện lên, và chuyển cấu hình (profile, project, location) vào env của request; việc lấy credential thật thuộc về SDK lúc gửi. Azure thì ngược lại: xác thực là một khoá thường, cái khó nằm ở endpoint riêng của từng resource.
Amazon Bedrock
resolve: async ({ ctx, credential, signal }) => { const env = async (name: string) => { signal.throwIfAborted(); const value = await ctx.env(name); signal.throwIfAborted(); return value; }; if (credential?.key) { return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" }; } if (await env("AWS_BEARER_TOKEN_BEDROCK")) return { auth: {}, source: "AWS_BEARER_TOKEN_BEDROCK" }; if (credential?.env?.AWS_PROFILE ?? (await env("AWS_PROFILE"))) { return { auth: {}, env: credential?.env, source: credential?.env?.AWS_PROFILE ? "stored credential" : "AWS_PROFILE", }; } if ((await env("AWS_ACCESS_KEY_ID")) && (await env("AWS_SECRET_ACCESS_KEY"))) { return { auth: {}, source: "AWS access keys" }; } if (await env("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")) return { auth: {}, source: "ECS task role" }; if (await env("AWS_CONTAINER_CREDENTIALS_FULL_URI")) return { auth: {}, source: "ECS task role" }; if (await env("AWS_WEB_IDENTITY_TOKEN_FILE")) return { auth: {}, source: "web identity token" }; return undefined;},Thứ tự là: khoá đã lưu (một bearer token của Bedrock), AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE (bản lưu trong env của credential trước), cặp AWS_ACCESS_KEY_ID và AWS_SECRET_ACCESS_KEY, vai trò của ECS, web identity (IRSA trên Kubernetes). Ngoài khoá đã lưu, mọi nhánh trả auth: {}: không có gì để chèn vào request. Lệnh đăng nhập cho chọn một trong ba cách và chỉ lưu đúng thứ người dùng nhập: token, tên profile, hoặc không gì cả với lựa chọn "chuỗi credential có sẵn" (amazon-bedrock.ts:11). Adapter quyết định phần còn lại khi dựng client:
const optionsProfile = options.profile || options.env?.AWS_PROFILE;const config: BedrockRuntimeClientConfig = { profile: optionsProfile || getProviderEnvValue("AWS_PROFILE", options.env), customUserAgent: bedrockUserAgent(options.identity),const skipAuth = getProviderEnvValue("AWS_BEDROCK_SKIP_AUTH", options.env) === "1";const bearerToken = options.bearerToken || options.apiKey || getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", options.env) || undefined;const useBearerToken = bearerToken !== undefined && !skipAuth; const arnRegionMatch = model.id.match(/^arn:aws(?:-[a-z0-9-]+)?:bedrock:([a-z0-9-]+):/); if (arnRegionMatch) { config.region = arnRegionMatch[1]; } else if (configuredRegion) { config.region = configuredRegion; } else if (endpointRegion && useExplicitEndpoint) { config.region = endpointRegion; } else if (!hasAmbientConfiguredProfile) { config.region = "us-east-1"; const credentials = getConfiguredBedrockCredentials(options.env); if (!skipAuth && credentials && !optionsProfile) { config.credentials = credentials; } const proxyUrl = resolveHttpProxyUrlForTarget(model.baseUrl, options.env); if (proxyUrl) { // Bedrock runtime uses NodeHttp2Handler by default since v3.798.0, which is based // on `http2` module and has no support for http agent. // Use NodeHttpHandler to support HTTP(S) proxy agents. config.requestHandler = new NodeHttpHandler({ httpAgent: new HttpProxyAgent(proxyUrl), httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent, });if (useBearerToken) { config.token = { token: bearerToken }; config.authSchemePreference = ["httpBearerAuth"];}- Bearer hay SigV4. Có bearer token (từ option, từ khoá đã lưu đi qua
apiKey, hoặc từ biến môi trường) thì client dùnghttpBearerAuthvà bỏ qua chữ ký SigV4. Không có thì SDK AWS tự đi chuỗi credential mặc định của nó. - Profile thắng khoá môi trường. Profile chọn qua luồng đăng nhập của Pi phải thắng cặp khoá IAM trong môi trường, mà chuỗi mặc định của SDK chỉ làm vậy khi
credentialskhông được đặt; vì thế khoá IAM chỉ được gán tường minh khi không có profile tường minh (lỗi #6957). - Vùng. Vùng nhúng trong ARN của mô hình (inference profile) thắng tất cả, để một
AWS_REGIONđặt cho dịch vụ khác không phá request; rồi tớiregion,AWS_REGION,AWS_DEFAULT_REGION; cuối cùng làus-east-1khi không có profile nào. - Endpoint tuỳ biến. Một
baseUrlkhông phải endpoint chuẩn của Bedrock (VPC endpoint, proxy) luôn được giữ; endpoint chuẩn chỉ được ghim khi không có vùng hay profile cấu hình.AWS_BEDROCK_SKIP_AUTH=1gửi credential giả cho proxy không cần xác thực;AWS_BEDROCK_FORCE_HTTP1=1cho endpoint không nói HTTP/2. - Proxy. Từ v3.798.0 SDK dùng handler HTTP/2 không hỗ trợ agent, nên khi có biến proxy, adapter thay bằng
NodeHttpHandlervớiHttpProxyAgent/HttpsProxyAgent(mục mạng).
Google Vertex AI
resolve: async ({ ctx, credential, signal }) => { const env = async (name: string) => { signal.throwIfAborted(); const value = await ctx.env(name); signal.throwIfAborted(); return value; }; const key = credential?.key ?? (await env("GOOGLE_CLOUD_API_KEY")); if (key) return { auth: { apiKey: key }, source: credential?.key ? "stored credential" : "GOOGLE_CLOUD_API_KEY" }; const adcPath = credential?.env?.GOOGLE_APPLICATION_CREDENTIALS ?? (await env("GOOGLE_APPLICATION_CREDENTIALS")); signal.throwIfAborted(); const hasCredentials = await ctx.fileExists(adcPath ?? VERTEX_ADC_PATH); signal.throwIfAborted(); const project = credential?.env?.GOOGLE_CLOUD_PROJECT ?? (await env("GOOGLE_CLOUD_PROJECT")) ?? (await env("GCLOUD_PROJECT")); const location = credential?.env?.GOOGLE_CLOUD_LOCATION ?? (await env("GOOGLE_CLOUD_LOCATION")); if (hasCredentials && project && location) { return { auth: {}, env: credential?.env, source: credential ? "stored credential" : "gcloud application default credentials", }; } return undefined;},Vertex nhận hoặc một khoá Google Cloud (GOOGLE_CLOUD_API_KEY), hoặc Application Default Credentials (ADC). Với ADC, provider chỉ xác nhận ba điều: có file credential (đường dẫn trong GOOGLE_APPLICATION_CREDENTIALS, hoặc file mặc định mà gcloud auth application-default login ghi), có project, có location. Adapter khi đó dựng new GoogleGenAI({ vertexai: true, project, location }), thêm googleAuthOptions.keyFilename nếu có file tài khoản dịch vụ, và để thư viện của Google tự lấy và làm mới token (google-vertex.ts:364). Khoá dạng giữ chỗ như <authenticated> bị bỏ qua thay vì gửi đi (google-vertex.ts:442).
Azure OpenAI và Microsoft Foundry
Mỗi người dùng Azure có một resource riêng, nên catalog không thể chứa URL. Bộ sinh catalog chép các mô hình OpenAI sang provider azure với baseUrl: "" (và DeepSeek V4 Pro với giá riêng của Azure), còn URL được tính ở từng request (generate-models.ts:3340). Thứ tự: azureBaseUrl trong option, AZURE_OPENAI_BASE_URL, azureResourceName hoặc AZURE_OPENAI_RESOURCE_NAME (thành https://<tên>.openai.azure.com/openai/v1), rồi mới tới model.baseUrl; URL gốc của *.openai.azure.com, *.cognitiveservices.azure.com, *.ai.azure.com được chuẩn hoá về /openai/v1 (azure-openai-config.ts:72). Mô hình OpenAI đi qua azure-openai-responses; mô hình Foundry đi qua Chat Completions, bọc như sau:
/** Send the deployment name as the request's model, keeping `model.id` as the catalog id. */function withDeploymentName<T extends StreamOptions>(model: Model<Api>, options: T | undefined): T | undefined { const deploymentName = resolveDeploymentName(model, options); if (deploymentName === model.id) return options; return { ...options, onPayload: async (payload, payloadModel) => { const params = { ...(payload as object), model: deploymentName }; return (await options?.onPayload?.(params, payloadModel)) ?? params; }, } as T;}/** * Resolve the Azure endpoint and deployment before dispatch, inside `lazyStream` so an * unconfigured endpoint errors on the stream instead of throwing out of `stream()`. */function azureStreams(streams: ProviderStreams): ProviderStreams { return { stream: (model, context, options) => lazyStream(model, async () => streams.stream(resolveAzureModel(model, options), context, withDeploymentName(model, options)), ), streamSimple: (model, context, options) => lazyStream(model, async () => streams.streamSimple(resolveAzureModel(model, options), context, withDeploymentName(model, options)), ), };}Hai mẹo nhỏ đáng học. Tên deployment được thay vào payload qua onPayload, nên model.id vẫn là id trong catalog (để tính giá, hiển thị, khôi phục session) còn máy chủ nhận tên deployment; ánh xạ đến từ azureDeploymentName hoặc AZURE_OPENAI_DEPLOYMENT_NAME_MAP=id=deployment,.... Và việc tính endpoint nằm trong lazyStream(), nên thiếu cấu hình thành một sự kiện error trên luồng chứ không ném ra khỏi stream().
Anthropic workload identity federation
Từ 0.99.2, một máy chủ CI hay một pod không có khoá Anthropic vẫn gọi được API bằng cách đổi một identity token của nền tảng (OIDC) lấy token truy cập ngắn hạn (CHANGELOG 0.99.2). Provider xét nó cuối cùng, sau mọi khoá và ANTHROPIC_AUTH_TOKEN, và chỉ chuyển các id qua env vì chúng là cấu hình chứ không phải xác thực (anthropic.ts:49). Adapter dựng cấu hình federation cho SDK:
/** * Workload identity federation config from the ANTHROPIC_* variables the * Anthropic SDK documents; the SDK performs the token exchange and refresh. * Only for the anthropic provider, since the exchange is an Anthropic API * endpoint, and only when no key or auth header was resolved. */function getAnthropicFederation( model: Model<"anthropic-messages">, apiKey: string | undefined, headers: ProviderHeaders | undefined, env: ProviderEnv | undefined,): AnthropicFederationConfig | undefined { if (model.provider !== "anthropic" || hasRequestAuth(apiKey, headers)) return undefined; const federationRuleId = getProviderEnvValue(ANTHROPIC_FEDERATION_RULE_ID_ENV, env); const organizationId = getProviderEnvValue(ANTHROPIC_ORGANIZATION_ID_ENV, env); const identityTokenFile = getProviderEnvValue(ANTHROPIC_IDENTITY_TOKEN_FILE_ENV, env); if (!federationRuleId || !organizationId || !identityTokenFile) return undefined; return { organization_id: organizationId, workspace_id: getProviderEnvValue(ANTHROPIC_WORKSPACE_ID_ENV, env), authentication: { type: "oidc_federation", federation_rule_id: federationRuleId, service_account_id: getProviderEnvValue(ANTHROPIC_SERVICE_ACCOUNT_ID_ENV, env), identity_token: { source: "file", path: identityTokenFile }, }, };}SDK của Anthropic thực hiện việc đổi token và tự làm mới, đọc lại file identity token mỗi lần, nên tài liệu nhắc giữ file đó luôn mới với phiên dài (docs/providers.md:69). Vì pi dựng một client cho mỗi request mà SDK cache token theo client, adapter giữ một client federation duy nhất cho mỗi cấu hình và nhân bản nó bằng withOptions(), cách dùng chung bộ cache token (anthropic-messages.ts:1088). Mọi client khác là PiAnthropic, lớp con tắt chuỗi tìm credential riêng của SDK: federation chỉ chạy khi chính pi đã quyết định dùng nó.
Cloudflare: REST, AI Gateway và AI binding trong Worker#
Cloudflare có hai provider, và cả hai gặp cùng một vấn đề với catalog: URL chứa id tài khoản (và id gateway) của từng người dùng. Catalog vì thế lưu URL có placeholder, ví dụ https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1 cho mô hình chat của Workers AI (api/cloudflare.ts), và lời giải chia làm hai nửa. Nửa thứ nhất là xác thực: resolve() lấy khoá, account id và gateway id, mỗi trường ưu tiên giá trị trong credential rồi mới tới môi trường, để một credential chỉ lưu khoá vẫn lấy được account id từ biến môi trường (cloudflare-auth.ts:10). Các id đi ra trong env của kết quả. Nửa thứ hai là một wrapper quanh API, thay placeholder bằng giá trị trong options.env ngay trước khi gửi:
export function resolveCloudflareModel<TModel extends { baseUrl: string }>( model: TModel, env: ProviderEnv | undefined,): TModel { if (!env) return model; const baseUrl = model.baseUrl .replaceAll(`{${CLOUDFLARE_ACCOUNT_ID}}`, env[CLOUDFLARE_ACCOUNT_ID] ?? `{${CLOUDFLARE_ACCOUNT_ID}}`) .replaceAll(`{${CLOUDFLARE_GATEWAY_ID}}`, env[CLOUDFLARE_GATEWAY_ID] ?? `{${CLOUDFLARE_GATEWAY_ID}}`); return baseUrl === model.baseUrl ? model : { ...model, baseUrl };}/** * Wrap an API implementation so Cloudflare account/gateway endpoint * placeholders materialize from the resolved provider env before dispatch. */export function cloudflareStreams(streams: ProviderStreams): ProviderStreams { return { stream: (model, context, options) => streams.stream(resolveCloudflareModel(model, options?.env), context, options), streamSimple: (model, context, options) => streams.streamSimple(resolveCloudflareModel(model, options?.env), context, options), };}Thiết kế này giữ đúng ranh giới của ModelAuth: account id không phải xác thực, nên nó đi qua env, và chỉ phần API của provider biết URL được dựng thế nào. Changelog của 0.80.4, 0.80.7 và 0.80.8 ghi lại ba lần sửa lỗi khiến placeholder sống sót tới request và trả về 404, ví dụ khi credential lưu bằng /login chỉ có khoá (CHANGELOG 0.80.4).
Workers AI qua REST
Mô hình chat của cloudflare-workers-ai nói Chat Completions ở endpoint /ai/v1 tương thích OpenAI, xác thực bằng khoá Cloudflare như một Bearer thông thường. Classifier (Jev của TypeSafe, Clef của Cloudflare) đi qua API REST gốc: POST /accounts/{account}/ai/run với { model, input }, nhận về phong bì { success, result } của Cloudflare. Adapter cloudflare-workers-ai-system-one chỉ khác typesafe-system-one ở cách bọc và bóc phong bì đó, kể cả hai hình dạng kết quả khác nhau giữa mô hình bên thứ ba và mô hình do Cloudflare host (cloudflare-workers-ai-system-one.ts:17).
AI Gateway qua HTTPS
cloudflare-ai-gateway chọn passthrough theo hãng gốc: mô hình OpenAI đi tới .../{gateway}/openai bằng Responses, mô hình Anthropic tới .../anthropic bằng Messages (id được đổi dấu chấm thành gạch ngang vì Anthropic chỉ nhận dạng đó), mô hình workers-ai/* tới .../compat bằng Chat Completions (generate-models.ts:1988). Khoá Cloudflare xác thực Pi với gateway, không phải với hãng gốc:
resolve: async ({ ctx, credential, signal }) => { const resolved = await resolveCloudflareEnv("ai-gateway", ctx, credential, signal); if (!resolved) return undefined; return { auth: { headers: { "cf-aig-authorization": `Bearer ${resolved.apiKey}`, Authorization: null, "x-api-key": null, }, }, env: resolved.env, source: resolved.source, };},Giá trị null xoá header mà SDK tự đặt (Authorization của SDK openai, x-api-key của SDK Anthropic). Nếu để lại, gateway sẽ coi chúng là khoá của chính người dùng gửi cho hãng gốc (bring your own key) và dùng thay cho khoá đã lưu trong gateway. Việc xác thực với hãng gốc vì thế thuộc về gateway: tính tiền hợp nhất của Cloudflare, khoá lưu trong gateway, hoặc một header Authorization mà người dùng cấu hình trong models.json (docs/providers.md:212).
AI binding: khi code chạy bên trong một Worker
Gọi gateway qua HTTPS đòi một token API của Cloudflare, kể cả khi người gọi là một Worker nằm trong chính tài khoản đó. Một Worker có cách khác: binding env.AI có phương thức fetch() phục vụ passthrough của gateway ở https://workers-binding.ai/ai-gateway/gateways/{gateway}/{provider}/..., cùng hình dạng với URL HTTPS nhưng không có account id, vì kênh binding đã mang danh tính (cloudflare-ai-binding.ts:1). Phản hồi là định dạng gốc của hãng, nên mọi adapter chạy y như qua HTTPS. pi-ai chỉ thêm một hàm:
export interface AiBinding { aiGatewayLogId: string | null; fetch?(input: Request | string | URL, init?: RequestInit): Promise<Response>;}export const CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL = "cloudflare-gateway-binding";export function createAiBindingFetch(binding: AiBinding): FetchFunction { // `fetch` is optional on the type, so its presence is checked here — early, rather than as a // confusing failure on the first inference request. if (typeof binding.fetch !== "function") { throw new TypeError("createAiBindingFetch: the AI binding does not expose fetch()"); } // Bound eagerly: `fetch` is a mutable property, so the narrowing above would not survive into // the returned closure. const bindingFetch = binding.fetch.bind(binding); return (input, init) => bindingFetch(input, init);}Cách dùng không đi qua provider dựng sẵn nào: host tự khai báo một mô hình có baseUrl trỏ vào route của binding, truyền fetch: createAiBindingFetch(env.AI) trong option, và đặt cf-aig-authorization: Bearer cloudflare-gateway-binding cùng Authorization: null, x-api-key: null. Giá trị "sentinel" kia chỉ để qua được bước kiểm tra "có xác thực" của adapter (anthropic-messages.ts:316); gateway bỏ nó đi với request qua binding. Hàm thay cho createGatewayBindingFetch() cũ từ 0.85.0: thay vì viết lại request, nó chuyển nguyên phương thức, header và body dạng luồng tới binding (CHANGELOG 0.85.0). Hai adapter Gemini từ chối options.fetch tuỳ biến, nên đường này dùng được với các adapter dựa trên SDK openai và Anthropic.
Gateway và aggregator#
Với pi-ai, một gateway chỉ là một provider có catalog chứa mô hình của nhiều hãng. Không có lớp "gateway" nào trong code. Cái làm chúng khác provider thường là năm việc nhỏ, mỗi việc một chỗ trong code: chọn API theo từng mô hình, cá nhân hoá catalog theo khoá, header ghi nhận ứng dụng, header định tuyến theo phiên, và tuỳ chọn định tuyến tới hãng gốc.
OpenRouter: hai API trong một catalog
OpenRouter phục vụ cả endpoint tương thích OpenAI lẫn endpoint tương thích Anthropic. Bộ sinh catalog cho mô hình anthropic/* (trừ biến thể :batch) đi đường Anthropic, còn mọi mô hình khác đi Chat Completions. Hệ quả là Claude qua OpenRouter dùng chung adapter với mọi mô hình Claude khác, kể cả điểm ngắt cache và suy nghĩ có chữ ký:
const thinkingLevelMap = getOpenRouterThinkingLevelMap(model.reasoning);const useAnthropicMessages = /^anthropic\//.test(model.id) && !model.id.endsWith(":batch");chat.push({ type: "chat", id: model.id, name: model.name, api: useAnthropicMessages ? "anthropic-messages" : "openai-completions", baseUrl: useAnthropicMessages ? "https://openrouter.ai/api" : "https://openrouter.ai/api/v1", provider: "openrouter", reasoning: model.supported_parameters?.includes("reasoning") || false, ...(thinkingLevelMap && { thinkingLevelMap }), input, cost: openRouterCost(model.pricing), contextWindow: model.top_provider?.context_length || model.context_length || 4096, maxTokens: model.top_provider?.max_completion_tokens || 4096,});Một mô hình anthropic/* của provider openrouter mà vẫn đi Chat Completions (biến thể :batch, hoặc một mục thêm vào openrouter trong models.json) vẫn được gắn cache_control kiểu Anthropic, vì detectCompat() đặt cacheControlFormat: "anthropic" cho nó (openai-completions.ts:1650). Trên đường Completions, mức suy nghĩ đi dạng reasoning: { effort } (thinkingFormat: "openrouter"). Trên cả hai đường, sessionId thành header x-session-id, để OpenRouter đưa các request cùng phiên về cùng nơi cho cache trúng (anthropic-messages.ts:218).
Catalog theo khoá
Một khoá OpenRouter có thể bị giới hạn bởi guardrail của tổ chức: chỉ một số mô hình, chỉ một số hãng gốc, với giá và giới hạn riêng. pi-ai có hàm đọc đúng danh sách đó:
export async function personalizeOpenRouterCatalog( catalog: readonly AnyModel[], context: RefreshModelsContext,): Promise<readonly AnyModel[]> { const key = context.credential?.type === "oauth" ? context.credential.access : context.credential?.key; if (!key) return catalog; const response = await fetch(`${(context.baseUrl ?? OPENROUTER_BASE_URL).replace(/\/+$/, "")}/models/user`, { headers: { accept: "application/json", authorization: `Bearer ${key}` }, signal: AbortSignal.any([context.signal, AbortSignal.timeout(OPENROUTER_USER_MODELS_TIMEOUT_MS)]), }); if (response.status === 401 || response.status === 403 || response.status === 404) return catalog; if (!response.ok) throw new Error(`OpenRouter model list request failed: ${response.status}`); return catalog.flatMap((model): AnyModel[] => { if (!isModelType(model, "chat")) return [model]; const entry = allowed.get(model.id); if (!entry) return []; return [ { ...model, // `||`: OpenRouter reports a missing limit as absent, null or 0, like the generator's fallback. contextWindow: entry.top_provider?.context_length || entry.context_length || model.contextWindow, maxTokens: entry.top_provider?.max_completion_tokens || model.maxTokens, cost: entry.pricing ? openRouterCost(entry.pricing) : model.cost, }, ]; });Mô hình chat không có trong danh sách của khoá bị bỏ; mô hình còn lại nhận cửa sổ ngữ cảnh, output tối đa và giá theo khoá. Mô hình ảnh và classifier giữ nguyên. Request dùng baseUrl đang cấu hình, nên endpoint vùng như https://us.openrouter.ai/api/v1 trả về danh sách đã lọc theo vùng; endpoint từ chối khoá hoặc không có route này (một proxy chẳng hạn) thì catalog giữ nguyên. Coding agent bọc provider bằng withPersonalizedCatalog(), lưu danh sách đã cá nhân hoá vào models-store.json để dùng khi ngoại tuyến, ở chế độ pi -p và RPC (docs/providers.md:116). Giá của OpenRouter tính theo USD mỗi token dạng chuỗi; openRouterCost() đổi sang USD mỗi triệu token, biến giá theo độ dài prompt thành các bậc giá, và bỏ qua giá theo giờ trong ngày vì ModelCost không biểu diễn được (openrouter-api.ts:45).
Header ghi nhận ứng dụng (attribution)
OpenRouter có bảng xếp hạng ứng dụng, NVIDIA và OpenCode ghi nhận ứng dụng gọi tới. Header tương ứng do identity của request quyết định (chương pi-ai, danh tính của client). Một chi tiết quan trọng là cách nhận ra đích: theo id provider hoặc theo host của baseUrl, nên một provider tự định nghĩa trỏ vào openrouter.ai cũng gửi đúng header.
function isOpenRouter(target: ClientIdentityTarget): boolean { return target.provider === "openrouter" || target.baseUrl.includes("openrouter.ai");}function isNvidiaNim(target: ClientIdentityTarget): boolean { return target.provider === "nvidia" || matchesHost(target.baseUrl, NVIDIA_NIM_HOST);}function isOpenCode(target: ClientIdentityTarget): boolean { return ( target.provider === "opencode" || target.provider === "opencode-go" || matchesHost(target.baseUrl, OPENCODE_HOST) );}Coding agent không gửi attribution vô điều kiện. Nó gắn PI_ATTRIBUTION chỉ khi install telemetry đang bật, đọc lại ở mỗi request:
/** * How pi's requests identify it to providers: a detailed `User-Agent` with pi's version, and attribution to pi as a * CLI agent while install telemetry is enabled. */export function getClientIdentity(settingsManager: SettingsManager): ClientIdentity { return { userAgent: "detailed", version: VERSION, attribution: isInstallTelemetryEnabled(settingsManager) ? PI_ATTRIBUTION : null, };}enableInstallTelemetry: false trong settings hoặc PI_TELEMETRY=0 tắt cả báo cáo cài đặt lẫn header ghi nhận (docs/settings.md:170); User-Agent chi tiết vẫn được gửi.
Vercel AI Gateway, OpenCode, định tuyến tới hãng gốc
- Vercel AI Gateway có một endpoint tương thích Anthropic cho mọi hãng, nên provider chỉ cần một API cho toàn bộ catalog, và đánh dấu
allowEmptySignaturecho các mô hình của nó (generate-models.ts:1432). Classifier Jev đi qua endpoint System One của gateway ở/typesafe/v1. - OpenCode Zen và Go chọn API theo gói SDK mà models.dev ghi cho từng mô hình (
@ai-sdk/anthropicthành Messages,@ai-sdk/openaithành Responses,@ai-sdk/googlethành Gemini, còn lại là Completions), có sửa tay những chỗ models.dev ghi sai (generate-models.ts:2234). Headerx-opencode-sessionlà bắt buộc để OpenCode định tuyến hội thoại; nó được chèn ở hai tầng: wrapper của provider dựng sẵn, và tầng runtime (Models.applyAuth()của pi-ai,prepareRequest()của coding agent), tầng sau phủ cả provider tự định nghĩa trỏ vàoopencode.ai(identity.ts:126). - Định tuyến tới hãng gốc là cờ compat:
openRouterRoutingthành trườngprovidercủa request (danh sách hãng, cho phép fallback, chính sách thu thập dữ liệu...),vercelGatewayRoutingthànhproviderOptions.gatewayvớionlyvàorder(openai-completions.ts:993). Đặt chúng trongmodels.jsonbằngmodelOverrideshoặccompatcấp provider.
Radius: gateway nói giao thức của Pi
Radius là gateway do Earendil làm cho Pi, có kiểm soát theo tổ chức và thống kê (docs/providers.md:104). Nó nói pi-messages, nên client không cần SDK của hãng nào. Provider được viết tay vì catalog của nó khác mọi provider khác: chủ tổ chức có thể tắt mô hình, nên một khi đã biết catalog của tài khoản (từ GET /v1/config của gateway), catalog đó thay thế bản dựng sẵn thay vì phủ lên nó; bản dựng sẵn chỉ dùng khi chưa có gì (radius.ts:21). Một gateway Radius riêng được khai báo trong models.json bằng "oauth": "radius" cộng baseUrl; coding agent dựng một radiusProvider() mới cho id đó, với đăng nhập OAuth và catalog trỏ vào gateway ấy (model-runtime.ts:271).
Một mô hình, nhiều đường
Vì gateway và nền tảng suy luận bán lại mô hình của hãng khác, cùng một mô hình gốc thường có nhiều mục trong catalog, mỗi mục một id, một API, một giá và một giới hạn. Lấy Kimi K2.6 của Moonshot: bảng mô hình mặc định của coding agent đã cho thấy ít nhất bảy đường (model-resolver.ts:20):
Tham chiếu provider/id | Đi qua | Credential |
|---|---|---|
moonshotai/kimi-k2.6 | API của hãng, api.moonshot.ai | MOONSHOT_API_KEY |
moonshotai-cn/kimi-k2.6 | API của hãng ở Trung Quốc, api.moonshot.cn | MOONSHOT_API_KEY |
openrouter/moonshotai/kimi-k2.6 | OpenRouter, rồi một hãng gốc do OpenRouter chọn | OPENROUTER_API_KEY |
huggingface/moonshotai/Kimi-K2.6 | Router của Hugging Face | HF_TOKEN |
opencode/kimi-k2.6 | OpenCode Zen | OPENCODE_API_KEY |
cloudflare-workers-ai/@cf/moonshotai/kimi-k2.6 | Workers AI của Cloudflare | khoá và account id Cloudflare |
cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.6 | AI Gateway, rồi Workers AI | khoá, account id, gateway id |
Với Claude, các đường khác nhau cả ở API: anthropic gọi thẳng bằng Messages, amazon-bedrock bằng ConverseStream (id dạng us.anthropic.claude-...), còn github-copilot, openrouter, vercel-ai-gateway và cloudflare-ai-gateway đều nói Messages tới endpoint tương thích của mình, theo các luật chọn API đã thấy ở trên. Session của Pi lưu cả provider lẫn model, nên đổi đường giữa hội thoại là một lần chuyển giao giữa hai mô hình khác nhau theo nghĩa của transformMessages(): suy nghĩ của đường cũ bị bỏ (chương pi-ai).
Mô hình cục bộ và tự host#
Có hai đường cho mô hình chạy trên máy của mình. Với llama.cpp, coding agent có sẵn một provider biết router của nó. Với mọi máy chủ khác nói một API có sẵn (Ollama, LM Studio, vLLM, SGLang, một proxy), người dùng khai báo nó trong models.json (docs/models.md:37).
Extension dựng sẵn llama.cpp
Provider này không nằm trong pi-ai. Nó là một trong bốn extension dựng sẵn của coding agent (extensions/index.ts:7), đăng ký một Provider hoàn chỉnh qua pi.registerProvider() và một lệnh /llama để nạp, gỡ, tải mô hình về router (llama/index.ts:42). Router của llama.cpp phát hiện các file GGUF trong một thư mục và nạp chúng theo yêu cầu, nên danh mục mô hình của provider là danh mục động của router chứ không phải một catalog sinh sẵn (docs/llama-cpp.md).
apiKey: { name: "llama.cpp server", login: async (interaction): Promise<ApiKeyCredential> => { await new LlamaClient(serverUrl, apiKey || undefined).list({ signal: interaction.signal }); return { type: "api_key", key: apiKey || undefined, env: { LLAMA_BASE_URL: serverUrl }, }; }, check: async ({ ctx, credential }) => { const serverUrl = await resolveServerUrl(ctx, credential); return serverUrl ? { type: "api_key", source: credential ? "stored credential" : "LLAMA_BASE_URL" } : undefined; }, resolve: async ({ ctx, credential }): Promise<AuthResult | undefined> => { const serverUrl = await resolveServerUrl(ctx, credential); if (!serverUrl) return undefined; const apiKey = credential?.key ?? (await ctx.env("LLAMA_API_KEY")) ?? "local"; return { auth: { apiKey, baseUrl: llamaInferenceUrl(serverUrl) }, env: { ...credential?.env, LLAMA_BASE_URL: serverUrl }, source: credential ? "stored credential" : "LLAMA_BASE_URL", }; },/login llama.cpphỏi URL và một khoá tuỳ chọn, gọi thửGET /modelsđể chắc máy chủ ở chế độ router, rồi lưu URL vàoenv.LLAMA_BASE_URLcủa credential. Không đăng nhập thìLLAMA_BASE_URLvàLLAMA_API_KEYtrong môi trường cũng đủ.resolve()trả cảbaseUrl, nên URL thật của request được quyết định lúc phân giải xác thực, cùng cơ chế mà Copilot dùng cho endpoint theo tài khoản. Khoá mặc định là chuỗi"local", vì SDKopenaikhông chấp nhận khoá rỗng.check()chỉ xem đã có URL hay chưa, không gọi mạng, nên việc liệt kê mô hình dùng được không chạm tới máy chủ.
refreshModels() đọc danh sách của router. Mô hình chọn được là mô hình đang nạp, đang ngủ (request sẽ đánh thức nó), hoặc preset chưa nạp khi router bật tự nạp. Với mỗi mô hình đang nạp, provider gọi thêm /props để đọc chat template và cấu hình thật của nó:
function toPiModel( model: LlamaModelInfo, serverUrl: string, props?: LlamaServerProps, cachedContextWindow?: number,): Model<"openai-completions"> { const contextWindow = contextWindowOf(model, cachedContextWindow); const reasoning = props?.chat_template?.includes("enable_thinking") === true; return { id: model.id, name: model.id, api: "openai-completions", provider: LLAMA_PROVIDER_ID, baseUrl: llamaInferenceUrl(serverUrl), reasoning, ...(reasoning && { thinkingLevelMap: { off: "off", minimal: null, low: null, medium: "medium", high: null, xhigh: null }, }), input: model.architecture?.input_modalities?.includes("image") ? ["text", "image"] : ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow, maxTokens: contextWindow, compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false, supportsUsageInStreaming: true, supportsStrictMode: false, maxTokensField: "max_tokens", ...(reasoning && { thinkingFormat: "qwen-chat-template" }), }, };Mô hình được coi là có suy nghĩ khi chat template chứa enable_thinking, và khi đó mức suy nghĩ đi qua chat_template_kwargs (thinkingFormat: "qwen-chat-template") với đúng hai mức off và medium. Cửa sổ ngữ cảnh lấy theo thứ tự: n_ctx lúc chạy, cờ --ctx-size trong tham số của mô hình, giá trị đã cache, n_ctx_train, cuối cùng là 128.000 (provider.ts:71). Các cờ compat đều là khác biệt đã kiểm chứng của llama-server: không có store, không có vai developer, không có reasoning_effort, dùng max_tokens. Mỗi mô hình còn được liệt kê làm classifier: mô hình quyết định (router báo decisions trong output_modalities, từ llama.cpp 0.6.0) đi qua endpoint System One gốc của llama.cpp, mô hình chat đi qua llama-cpp-classify, đọc xác suất của nhãn đáp án ở token kế tiếp (docs/llama-cpp.md:89).
Phần stream của provider gọi stream và streamSimple của @earendil-works/pi-ai/compat, tức API toàn cục chọn adapter theo model.api (provider.ts:288). Lệnh /llama chỉ có trong TUI; sau mỗi lần nạp, gỡ hay tải, nó làm mới catalog của riêng provider này với allowNetwork: true, kể cả khi PI_OFFLINE được đặt, vì người dùng vừa chủ động liên lạc với máy chủ (llama/index.ts:46). Không muốn provider này thì tắt nó bằng "extensions": ["-builtin:llama.cpp"]. Tài liệu nvidia-rtx-llama-cpp.md hướng dẫn đổi một lệnh llama-server tối ưu cho GPU NVIDIA thành một preset của router.
Ollama, LM Studio, vLLM và máy chủ tương thích khác
Những máy chủ này đi qua models.json với "api": "openai-completions", một baseUrl và danh sách mô hình khai báo tay. Không có bước phát hiện mô hình: muốn danh sách động như llama.cpp thì phải viết một extension có refreshModels. Ví dụ trong tài liệu dùng một khoá giả "ollama": Ollama bỏ qua nó, nhưng không có khoá thì provider bị coi là chưa cấu hình và mô hình không hiện trong /model (docs/models.md:45). Lần theo cách khai báo đó tới request ở trace thứ hai.
Với một máy chủ lạ, detectCompat() không nhận ra gì và trả về mặc định của chính OpenAI: gửi store: false, vai developer cho mô hình có suy nghĩ, reasoning_effort, max_completion_tokens. README ghi rằng Ollama, vLLM và SGLang thường cần tắt supportsDeveloperRole và supportsReasoningEffort (README). Các cờ dành cho máy chủ tự host gồm: thinkingFormat "qwen", "qwen-chat-template" hay "chat-template" với chatTemplateKwargs chứa biến $var do Pi điền; thinkingTokenBudgetField để giới hạn token suy nghĩ (thinking_token_budget của vLLM, thinking_budget của Qwen và SGLang, thinking_budget_tokens của llama.cpp); vllmPriority cho bộ lập lịch ưu tiên của vLLM (docs/models-configuration.md:123). Tài liệu nhấn mạnh một nguyên tắc: chỉ bật cờ compat cho khác biệt đã kiểm chứng, không bật chỉ vì máy chủ quảng cáo "tương thích OpenAI" (docs/models.md:140).
models.json và cách ghép với catalog dựng sẵn#
<agent-dir>/models.json là nơi người dùng sửa thế giới provider mà không viết code. Mỗi khoá dưới providers là một id provider: id của provider dựng sẵn thì sửa provider đó, id khác thì tạo provider mới, khi ấy cần baseUrl và một api ở cấp provider hoặc cấp mô hình (docs/models-configuration.md:9). Lược đồ viết bằng TypeBox, ghép lại các lược đồ mô hình và compat của pi-ai:
const ProviderConfigSchema = Type.Object({ name: Type.Optional(Type.String({ minLength: 1, description: "Display name." })), baseUrl: Type.Optional( Type.String({ minLength: 1, description: "API base URL. Also applies to the provider's built-in models." }), ), apiKey: Type.Optional( Type.String({ minLength: 1, description: 'Literal key, "$NAME" environment interpolation, or "!command" output.', }), ), api: Type.Optional( Type.String({ minLength: 1, description: 'API for the provider\'s models, such as "openai-completions", "openai-responses", "anthropic-messages", or "google-generative-ai".', }), ), oauth: Type.Optional(Type.Literal("radius", { description: "OAuth login flow. Requires baseUrl." })), headers: Type.Optional(HeadersSchema), compat: Type.Optional(ProviderCompatSchema), authHeader: Type.Optional( Type.Boolean({ default: false, description: "Whether to send the resolved API key as Authorization: Bearer." }), ), models: Type.Optional(Type.Array(ModelDefinitionSchema)), modelOverrides: Type.Optional(Type.Record(Type.String(), ModelOverrideSchema)),});ModelConfig.load() bỏ comment và BOM, kiểm tra cả file bằng validator đã biên dịch, rồi đóng băng sâu kết quả. File hỏng không làm Pi dừng: cấu hình rỗng được dùng, lỗi được giữ lại để hiện cho người dùng (model-config.ts:168). File được đọc lại ở mỗi ModelRuntime.refresh(), ví dụ mỗi lần mở /model, nên sửa xong không cần khởi động lại.
Ghép theo từng provider
Nguyên tắc đầu tiên của ModelRuntime: không có lớp phủ thì dùng nguyên provider gốc, để đăng nhập, xác thực và stream của nó chạy đúng như pi-ai định nghĩa.
private composeProvider(providerId: string): Provider | undefined { const base = this.nativeExtensionProviders.get(providerId) ?? this.builtins.get(providerId); const extension = this.extensionProviders.get(providerId); if (!this.config.getProvider(providerId) && !extension) { // No overlays: use the builtin untouched so its auth/login/stream behavior is exact. this.compositionErrors.delete(providerId); return base; } try { const provider = composeModelProvider(providerId, base, this.config, extension); this.compositionErrors.delete(providerId); return provider; } catch (error) { this.compositionErrors.set(providerId, error instanceof Error ? error.message : String(error)); return base; }}Ghép thất bại (ví dụ một mô hình mới thiếu api) thì lỗi được ghi theo provider và provider gốc vẫn chạy. Khi có lớp phủ, composeModelProvider() dựng một provider mới quanh provider gốc. Danh sách mô hình của nó được tính lại ở mỗi lần đọc, theo thứ tự: mô hình gốc, models.json, mô hình của extension, phép chiếu OAuth cũ của extension, cuối cùng là modelOverrides:
const models: AnyModel[] = baseModels.map((model) => { const baseUrl = config.oauth === "radius" ? model.baseUrl : (config.baseUrl ?? model.baseUrl); if (isModelType(model, "chat")) return { ...model, baseUrl, compat: mergeCompat(model.compat, config.compat) }; // Classifier APIs read their own compat fields, such as how a System One service takes images. if (isModelType(model, "classifier") && config.compat) { return { ...model, baseUrl, compat: { ...model.compat, ...config.compat } }; } return { ...model, baseUrl };});for (const definition of config.models ?? []) { const existingIndex = models.findIndex((model) => isModelType(model, "chat") && model.id === definition.id); const defaults = findModelDefaults(models, definition.id, definition.api ?? config.api); const model = modelFromJson(providerId, definition, config, defaults); if (existingIndex >= 0) models[existingIndex] = model; else models.push(model);}return models;baseUrlcấp provider áp lên mọi mô hình gốc của provider, trừ khi là gateway Radius. Đây là cách đi qua một proxy mà vẫn giữ catalog và xác thực dựng sẵn:{ "anthropic": { "baseUrl": "https://proxy.example.com/v1" } }(docs/models.md:69).compatcấp provider gộp vào mọi mô hình chat (bốn cờ dạng object nhưopenRouterRoutingđược gộp từng trường), và vào classifier, nơi các cờ System One sống.- Một mục
modelstrùng id thì thay mô hình gốc, khác id thì được thêm. Trường bỏ trống lấy mặc định củamodelFromJson(): cửa sổ 128.000 token, output 16.384, giá bằng 0, input chỉ chữ, không suy nghĩ (provider-composer.ts:212).apivàbaseUrlnếu thiếu thì lấy từ một mô hình gốc tương tự: cùng id, hoặc cùng API, hoặc mô hình Completions đầu tiên, cuối cùng là mô hình chat đầu tiên (provider-composer.ts:253). modelOverrideslà lớp trên cùng: sửa siêu dữ liệu của một mô hình có sẵn mà không thay danh sách; object nhưcostđược gộp từng trường; id không tồn tại bị bỏ qua.
Phần stream của provider ghép chọn adapter theo ba bậc:
const supportsBaseApi = (model: Model<Api>) => base?.getModels().some((entry) => entry.api === model.api) ?? false;const streamWith = ( model: Model<Api>, context: TranscriptContext, options: StreamOptions | undefined, simple: boolean,): AssistantMessageEventStream => lazyStream(model, async () => { if (extension?.streamSimple && model.api === extension.api) { return extension.streamSimple(model, context, options as SimpleStreamOptions); } if (base && supportsBaseApi(model)) { return simple ? base.streamSimple(model, context, options as SimpleStreamOptions) : base.stream(model, context, options); } const api = getApiProvider(model.api); if (!api) throw new Error(`No API provider registered for api: ${model.api}`); return simple ? api.streamSimple(model, context, options as SimpleStreamOptions) : api.stream(model, context, options); });Bậc thứ ba cho phép một mô hình trong models.json dùng một API mà provider gốc không có. Ví dụ, thêm vào provider azure một mô hình Foundry với "api": "openai-completions" là trường hợp tài liệu Azure mô tả (docs/providers.md:161); một provider hoàn toàn mới thì không có provider gốc, nên luôn đi bậc này.
Giá trị lấy từ môi trường hay từ lệnh
apiKey và mọi giá trị header trong models.json có cùng cú pháp: chữ thường là giá trị thật; $TÊN hoặc ${TÊN} là biến môi trường, chen được giữa chữ khác ("Bearer $TOKEN"); !lệnh lấy stdout của lệnh shell; $$ và $! cho ký tự $ và ! thật (resolve-config-value.ts:138). Khác với auth.json, giá trị của models.json được phân giải ở mỗi request và không cache, qua resolveConfigValueOrThrow(); biến thiếu thì request thất bại với thông báo nêu đúng tên biến (resolve-config-value.ts:229). Biến được tra trong env của credential trước rồi mới tới môi trường của tiến trình, nên một header "X-Account: $CLOUDFLARE_ACCOUNT_ID" nhận được giá trị lưu trong auth.json.
Các lớp catalog và models-store.json
Trước khi models.json được ghép, mỗi provider dựng sẵn đã được bọc một lớp catalog: withRemoteCatalog() cho mọi provider trừ Radius và OpenRouter, withPersonalizedCatalog() cho OpenRouter:
static async create(options: CreateModelRuntimeOptions = {}): Promise<ModelRuntime> { const credentials = new RuntimeCredentials(options.credentials ?? DefaultAuthStorage.create(options.authPath)); const modelsPath = options.modelsPath === null ? undefined : (options.modelsPath ?? join(getAgentDir(), "models.json")); const config = await ModelConfig.load(modelsPath); const modelsStore = options.modelsStore ?? (modelsPath ? new FileModelsStore(options.modelsStorePath ?? join(dirname(modelsPath), "models-store.json")) : new InMemoryCodingAgentModelsStore()); const builtinModelDataGeneratedAt = builtinProviderCatalog.getBuiltinModelDataGeneratedAt(); const providers = builtinProviderCatalog.builtinProviders().map((provider) => { if (provider.id === "radius") return provider; // OpenRouter narrows the catalog to the models, limits and prices of the user's key. if (provider.id === "openrouter") { return withPersonalizedCatalog( provider, personalizeOpenRouterCatalog, options.catalogBaseUrl, builtinModelDataGeneratedAt, ); } return withRemoteCatalog(provider, options.catalogBaseUrl, builtinModelDataGeneratedAt); });Cơ chế của lớp phủ pi.dev (kiểm tra tối đa bốn giờ một lần, ETag, chỉ dùng khi mới hơn dữ liệu đóng gói) đã ở chương pi-ai. Mọi catalog động được lưu chung trong models-store.json cạnh models.json: lớp phủ pi.dev, danh sách đã cá nhân hoá của OpenRouter, catalog của llama.cpp và của Radius. FileModelsStore dùng lại đúng backend có khoá file của auth.json (models-store.ts:46). Lúc khởi động, runtime chỉ khôi phục các catalog này từ đĩa, không gọi mạng (allowModelNetwork mặc định là false), để Pi mở nhanh và chạy được khi ngoại tuyến; chế độ interactive làm mới qua mạng ở nền, và PI_OFFLINE tắt hẳn việc đó (model-runtime.ts:248).
Provider do extension đăng ký#
Khi một dịch vụ cần đăng nhập riêng, danh mục động hay một giao thức chưa có, provider được viết trong một extension. Hai dạng đăng ký, thời điểm áp dụng và ngữ nghĩa "có models thì thay, chỉ có baseUrl thì giữ" đã được trình bày ở chương Extensions. Ở đây là vị trí của chúng trong chuỗi ghép đã thấy ở mục trước:
- Native provider,
pi.registerProvider(provider): mộtProviderhoàn chỉnh của pi-ai. Nó trở thành provider gốc của id đó, thay provider dựng sẵn cùng id, vàmodels.jsonvẫn ghép được lên trên nó (model-runtime.ts:899).llama.cppđi đường này. ProviderConfigtheo tên,pi.registerProvider("id", config): một lớp nằm saumodels.jsontronggetAllModels(). Đăng ký lại cùng tên thì gộp các trường đã định nghĩa lên lần trước. OAuth kiểu cũ (login(callbacks),refreshToken,getApiKey) đượcadaptOAuth()dịch sangOAuthAuth, nối các callback cũonAuth,onDeviceCode,onPromptvàonotify()vàprompt()(provider-composer.ts:358). Khai báostreamSimplethì phải khai báoapi, vì đó là khoá đểstreamWith()nhận ra mô hình nào đi stream tự viết.
Ủy quyền cho adapter có sẵn
Tài liệu khuyên dùng lại adapter của pi-ai bất cứ khi nào giao thức khớp, vì như vậy giữ được việc đổi message, xử lý tool, tính usage, huỷ và compat (docs/custom-provider.md:162). Ví dụ GitLab Duo làm đúng như thế: nó tự lo đăng nhập GitLab và đổi token GitLab lấy một token truy cập trực tiếp ngắn hạn của AI Gateway của GitLab, rồi giao phần còn lại cho adapter Anthropic hoặc OpenAI Responses:
const gitlabAccessToken = options?.apiKey;if (!gitlabAccessToken) throw new Error("No GitLab access token. Run /login gitlab-duo or set GITLAB_TOKEN");const cfg = MODEL_MAP.get(model.id);if (!cfg) throw new Error(`Unknown model: ${model.id}`);const directAccess = await getDirectAccessToken(gitlabAccessToken);const modelWithBaseUrl = { ...model, baseUrl: cfg.baseUrl };const headers = { ...directAccess.headers, Authorization: `Bearer ${directAccess.token}` };const streamOptions = { ...options, apiKey: "gitlab-duo", headers };const innerStream = cfg.backend === "anthropic" ? anthropicMessagesApi().streamSimple( { ...(modelWithBaseUrl as Model<"anthropic-messages">), compat: { ...(modelWithBaseUrl as Model<"anthropic-messages">).compat, forceAdaptiveThinking: true, }, }, context, streamOptions, ) : openAIResponsesApi().streamSimple( modelWithBaseUrl as Model<"openai-responses">, context, streamOptions, );for await (const event of innerStream) stream.push(event);Ba điều đáng học từ đoạn này. Credential mà Pi phân giải (token GitLab, từ /login gitlab-duo hoặc $GITLAB_TOKEN) chỉ là nguyên liệu; credential thật của request là token gateway, đi trong header. apiKey: "gitlab-duo" là giá trị giữ chỗ để qua bước kiểm tra "có xác thực" của adapter, vì header Authorization đã có. Và mô hình được sao ra với baseUrl của đúng backend trước khi giao, nên một provider có thể trộn mô hình của hai giao thức mà không cần bảng API.
Stream tự viết
Ví dụ custom-provider-anthropic tự hiện thực cả luồng bằng SDK Anthropic, dưới một API tự đặt custom-anthropic-api. Hợp đồng mà một stream tự viết phải giữ được tài liệu liệt kê thành sáu bước: tạo message với stopReason: "pending" và usage bằng 0, phát start sau khi request đã được chấp nhận, phát các khối cân bằng, chốt usage và chi phí, phát đúng một sự kiện cuối, đổi việc huỷ thành aborted (docs/custom-provider.md:172). Đây chính là giao thức luồng của pi-ai, cộng thêm ba móc đo đạc onPayload, onResponse, onProviderStreamEvent.
Mạng: proxy, base URL và fetch tuỳ biến#
Proxy
Coding agent cài một dispatcher toàn cục của undici ngay khi khởi động, nên mọi lời gọi fetch trong tiến trình, kể cả của SDK openai và Anthropic, đi qua nó:
export function applyHttpProxySettings(httpProxy: string | undefined): void { const proxy = httpProxy?.trim(); if (!proxy) return; process.env.HTTP_PROXY ??= proxy; process.env.HTTPS_PROXY ??= proxy;}export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS): void { const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs); if (normalizedTimeoutMs === undefined) { throw new Error(`Invalid HTTP idle timeout: ${String(timeoutMs)}`); } const dispatcher = withUndiciErrorListener( new undici.EnvHttpProxyAgent({ allowH2: false, // Keep HTTP origins on CONNECT tunnels as they were before Undici 8.7. proxyTunnel: true, bodyTimeout: normalizedTimeoutMs, connect: { autoSelectFamilyAttemptTimeout: DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS, }, headersTimeout: normalizedTimeoutMs, clientFactory: createUndiciClient, factory: createUndiciOriginDispatcher, }), ); undici.setGlobalDispatcher(dispatcher);EnvHttpProxyAgent đọc HTTP_PROXY, HTTPS_PROXY và NO_PROXY. Cài đặt httpProxy (chỉ đặt được trong settings của agent-directory) được chép vào hai biến đó bằng ??=, nên biến môi trường đã có thì thắng (docs/settings.md:125). Cùng dispatcher đặt thời gian chờ header và body theo httpIdleTimeoutMs (mặc định năm phút, 0 là tắt), và tắt HTTP/2.
Có hai chỗ không đi qua fetch, và pi-ai tự lo proxy ở đó bằng resolveHttpProxyUrlForTarget(): đọc http_proxy, https_proxy, all_proxy (chữ thường hoặc hoa, cả trong options.env), so khớp no_proxy theo miền và cổng, và từ chối proxy SOCKS hay PAC (node-http-proxy.ts:118). Chỗ thứ nhất là Bedrock, vì SDK AWS dùng handler HTTP riêng (mục đám mây ở trên). Chỗ thứ hai là WebSocket của Codex khi chạy trên Bun, vì WebSocket của Bun bỏ qua biến proxy (openai-codex-responses.ts:1016).
Base URL
URL của một request là model.baseUrl, nhưng giá trị đó có thể bị thay ở năm chỗ, theo thứ tự từ cấu hình tới lúc gửi: baseUrl của mục mô hình trong models.json; baseUrl cấp provider trong models.json hay trong registerProvider(), áp cho mọi mô hình của provider; baseUrl mà xác thực trả về (Copilot, llama.cpp), được applyAuth() hay prepareRequest() chép đè vào mô hình; wrapper của provider (placeholder của Cloudflare, endpoint của Azure); và cuối cùng là logic riêng của adapter, ví dụ Bedrock chỉ ghim endpoint chuẩn khi chưa cấu hình vùng, Vertex bỏ qua URL còn chứa {location}.
fetch tuỳ biến
options.fetch thay hàm fetch cho một request: hữu ích để chạy qua binding của Cloudflare, để ghi log, hay trong test. Adapter dựa trên SDK openai và Anthropic, adapter Mistral, pi-messages và các classifier truyền nó xuống. Hai adapter Gemini từ chối thẳng một fetch khác globalThis.fetch thay vì lặng lẽ bỏ qua nó, và Bedrock đi qua handler của SDK AWS nên không có khái niệm này.
Phía Pi Durable: host tự chọn provider#
Pi Durable (thử nghiệm) không có auth.json, models.json hay lớp phủ pi.dev nào. Harness nhận một Models, hoặc một hàm trả về Models, do host dựng (chương pi-ai, HarnessOptions). Host chọn provider một cách tường minh:
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";import { createModels } from "@earendil-works/pi-ai/models";import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";import { AssistantEntry, createRegistry, Harness, MemoryStorage } from "@earendil-works/pi-durable";const context = BACKGROUND_CONTEXT;const models = createModels();models.setProvider(openaiProvider()); // reads OPENAI_API_KEYconst harness = await Harness.open(new MemoryStorage(), { models, registry: createRegistry() }, context);const root = await harness.root(context, { agent: { model: { provider: "openai", modelId: "gpt-6-sol" } } });Đường import @earendil-works/pi-ai/models chỉ có runtime, không TypeBox, không catalog, không SDK; providers/openai chỉ kéo theo catalog của OpenAI và lazy wrapper của nó. Một Worker hay một ứng dụng trình duyệt vì thế chỉ trả giá cho đúng provider nó dùng. Xác thực ở đây là của pi-ai thuần: không truyền credentials thì kho credential là kho trong bộ nhớ, và khoá đến từ biến môi trường qua AuthContext mặc định; host muốn lưu token OAuth thì tiêm một CredentialStore bền của mình.
Conversation không lưu object mô hình mà lưu một tham chiếu { provider, modelId } (harness/types.ts:50), phân giải lại qua runtime.models.getModel() ở mỗi lượt sinh; tham chiếu không còn phân giải được thì input của lượt chạy được kết thúc là unanswered với lý do không có mô hình (generation.ts:218). Nhờ vậy host đổi được cấu hình provider (thêm khoá, đổi sang gateway) mà không phải sửa dữ liệu đã lưu. HarnessOptions.identity gộp lên DEFAULT_HARNESS_IDENTITY, tức attribution của pi theo mặc định; host đặt attribution của ứng dụng mình hoặc { attribution: null } (README).
Mô hình ảnh và classifier qua cùng provider#
Mô hình ảnh và classifier dùng chung provider, xác thực và catalog với mô hình chat; chỉ khác type và bảng thao tác (images, classifiers) trong createProvider(). Một OPENROUTER_API_KEY vì vậy phục vụ cả ba loại. Bản đồ ở v1.4.0:
| Loại | API | Provider |
|---|---|---|
| Sinh ảnh | openrouter-images | openrouter (danh sách lấy từ listing output_modalities=image của OpenRouter) |
| Classifier System One | typesafe-system-one | typesafe, openrouter, vercel-ai-gateway, opencode; mô hình quyết định của llama.cpp |
| Classifier trên Workers AI | cloudflare-workers-ai-system-one | cloudflare-workers-ai (Jev, Clef) |
| Classifier Decisions | openai-decisions | openai, chỉ với khoá API |
| Mô hình chat làm classifier | llama-cpp-classify | llama.cpp |
Cùng một giao thức System One được phục vụ ở các đường dẫn khác nhau tuỳ dịch vụ, và mỗi dịch vụ nhận ảnh theo một cách (assets của TypeSafe, danh sách images của Clef, phần image_url của OpenRouter); cờ compat.imageInput chọn cách, được tự nhận diện theo provider và host (typesafe-system-one.ts:5). Trong coding agent, các mô hình này không hiện trong /model; mô hình chat chạm tới chúng qua script codemode, extension qua ctx.modelRegistry.classify() và generateImages(). Phần đó thuộc chương Pi 1.0 và chương Codemode.
Trace: pi --model openrouter/... chỉ với OPENROUTER_API_KEY#
Kịch bản: máy chưa từng chạy /login, không có models.json, chỉ có biến OPENROUTER_API_KEY. Người dùng chạy pi --model openrouter/moonshotai/kimi-k2.6 (id mặc định của OpenRouter trong bảng của coding agent), gõ một câu hỏi, mức suy nghĩ để mặc định. Mỗi bước dưới đây trỏ vào code đã đọc ở các mục trước.
- Khởi động mạng
main.tsđọc settings toàn cục, chéphttpProxy(nếu có) vào biến môi trường rồi cài dispatcher undici toàn cục (main.ts:595). - Dựng runtime
ModelRuntime.create(): kho credential làRuntimeCredentialsbọcauth.json(rỗng),models.jsonkhông có, 42 provider dựng sẵn, riêngopenrouterbọcwithPersonalizedCatalog(). Vì không có lớp phủ nào choopenrouter,composeProvider()trả nguyên provider đã bọc. Lần làm mới đầu tiên chạy vớiallowNetwork: false: khôi phục danh sách cá nhân hoá từmodels-store.jsonnếu lần chạy trước đã lưu, không thì dùng catalog đóng gói. - Ai có credentialLượt kiểm tra khả dụng gọi
checkAuth()cho từng provider. Vớiopenrouter: không có credential đã lưu,envApiKeyAuthkhông cócheck(), nênresolve()chạy thật và thấyOPENROUTER_API_KEY. Provider được đánh dấu đã cấu hình với nguồn"OPENROUTER_API_KEY"; trong kịch bản này các provider khác thì không. - Phân giải
--modelresolveCliModel()dùng mọi mô hình, kể cả mô hình chưa có credential, để--api-keydùng được cho lần đầu (model-resolver.ts:445). TrongselectReference(), tiền tốopenrouterkhớp một provider, phần còn lạimoonshotai/kimi-k2.6khớp đúng một id của OpenRouter, nên kết quả là mô hình đó vớiapi: "openai-completions",baseUrl: "https://openrouter.ai/api/v1". - Request của vòng lặp agentMỗi lượt,
streamFncủa session dựng option (timeout, chính sách thử lại,identityđọc lại theo cài đặt telemetry,transformHeaderscho hookbefore_provider_headers) rồi gọimodelRuntime.streamSimple()(sdk.ts:340, sdk.ts:412). - Phân giải credential lúc gửi
prepareRequest()gọigetAuth(model): không cóapiKeytường minh;RuntimeCredentials.read()không có khoá--api-keynên hỏiauth.json, cũng không có; nhánh môi trường trả{ auth: { apiKey } }. Header được gộp: header OpenCode (không áp dụng), header xác thực (không có), header của option, rồitransformHeaders(model-runtime.ts:659). - Chọn adapter
provider.streamSimple()củacreateProvider()tra bảng theomodel.apivà lấy lazy wrapperopenAICompletionsApi()(models.ts:1106). Ở request đầu tiên, moduleopenai-completions.tsvà SDKopenaiđược nạp. - Cờ compat
detectCompat()nhận ra OpenRouter: mức suy nghĩ dạngreasoning: { effort }, vaisystemthay chodeveloper(chỉ mô hìnhanthropic/*vàopenai/*được dùngdeveloper), gửistore: false,max_completion_tokens, và bật header session affinity dạngx-session-id. - Dựng client và headerExcerpt dưới đây. Identity cho
User-Agentvà ba header ghi nhận,x-session-idmang id phiên, SDK thêmAuthorization: Bearer. - Gửi và đọcPOST
https://openrouter.ai/api/v1/chat/completions; thử lại tầng HTTP theo cài đặt; luồng SSE được đọc như ở chương pi-ai; chi phí tính bằng giá trong catalog, tức giá theo khoá nếu danh sách đã được cá nhân hoá.
function createClient( const headers: ProviderHeaders = { ...getIdentityHeaders(model, identity), ...model.headers }; if (model.provider === "github-copilot") { const hasImages = hasCopilotVisionInput(context.messages); const copilotHeaders = buildCopilotDynamicHeaders({ messages: context.messages, hasImages, }); Object.assign(headers, copilotHeaders); } if (sessionId && compat.sendSessionAffinityHeaders) { if (compat.sessionAffinityFormat === "openrouter") { headers["x-session-id"] = sessionId; } else { if (compat.sessionAffinityFormat === "openai") { headers.session_id = sessionId; } headers["x-client-request-id"] = sessionId; headers["x-session-affinity"] = sessionId; } } // Merge options headers last so they can override defaults if (optionsHeaders) { Object.assign(headers, optionsHeaders); } return new OpenAI({ apiKey, baseURL: model.baseUrl, dangerouslyAllowBrowser: true, fetch, defaultHeaders: headers, });Ví dụ minh hoạ: request gửi đi (rút gọn, giá trị là minh hoạ)
POST https://openrouter.ai/api/v1/chat/completions
Authorization: Bearer … ← OPENROUTER_API_KEY, do SDK openai thêm
User-Agent: pi/1.4.0 (linux; node/v24.x; x64) ← identity "detailed" của coding agent
HTTP-Referer: https://pi.dev ┐
X-OpenRouter-Title: pi ├ PI_ATTRIBUTION, chỉ khi bật install telemetry
X-OpenRouter-Categories: cli-agent ┘
x-session-id: 019a… ← sessionId, vì sendSessionAffinityHeaders
{ "model": "moonshotai/kimi-k2.6", "stream": true,
"messages": [{ "role": "system", ... }, { "role": "user", ... }],
"stream_options": { "include_usage": true }, "store": false,
"max_completion_tokens": …,
"reasoning": { "effort": … } } ← chỉ khi mô hình có suy nghĩmodel.headers, rồi header động, rồi header của người gọi. Một header cấu hình luôn thắng identity.Gateway khác hãng gốc ở đâu
Đổi lệnh thành pi --model moonshotai/kimi-k2.6 với MOONSHOT_API_KEY, cùng một mô hình gốc, và gần như mọi bước ở trên khác đi:
| Qua OpenRouter | Gọi thẳng Moonshot | |
|---|---|---|
| Endpoint, khoá | openrouter.ai/api/v1, OPENROUTER_API_KEY | api.moonshot.ai/v1, MOONSHOT_API_KEY |
| Catalog | Lọc theo khoá qua /models/user, giá và giới hạn theo khoá | Catalog đóng gói cộng lớp phủ pi.dev |
| Cờ compat | thinkingFormat: "openrouter", store: false, max_completion_tokens | Không store, không reasoning_effort, max_tokens |
| Header thêm | Attribution, x-session-id | Không có |
| Ai phục vụ thật | Một hãng gốc do OpenRouter chọn, trừ khi openRouterRouting giới hạn | Moonshot |
| Usage | chunk.usage, token cache trong prompt_tokens_details.cached_tokens | Có khi nằm trong choice.usage; adapter có đường lùi cho trường hợp này (openai-completions.ts:576) |
Còn nếu người dùng gõ --model moonshotai/kimi-k2.6 mà chỉ có OPENROUTER_API_KEY? Tiền tố moonshotai cũng là một provider, nên selectReference() có một ứng viên "theo tên" (kimi-k2.6 của moonshotai) và những ứng viên "nguyên văn" có id đúng bằng moonshotai/kimi-k2.6 ở provider khác, so khớp không phân biệt hoa thường. Chú thích của hàm nói rõ luật: provider/phần còn lại chỉ là một gợi ý; provider được nêu tên thắng nếu có credential, không thì một ứng viên nguyên văn duy nhất có credential thắng (model-resolver.ts:223). Ở đây moonshotai không có khoá còn openrouter có, nên lệnh lặng lẽ chạy qua OpenRouter. Nếu HF_TOKEN cũng được đặt, mục moonshotai/Kimi-K2.6 của Hugging Face trở thành ứng viên có credential thứ hai, và lệnh dừng với lỗi "ambiguous across providers" yêu cầu dùng --provider.
Cuối cùng, một id không có trong catalog (một mô hình OpenRouter vừa ra mắt hôm nay) không làm lệnh thất bại: resolveCliModel() sao mô hình mặc định của provider, đổi id, và in cảnh báo "Using custom model id" (model-resolver.ts:296). Request sẽ đi được, nhưng cửa sổ ngữ cảnh, giá và cờ compat là của mô hình mặc định chứ không phải của mô hình thật.
Trace ngắn: một mô hình Ollama khai báo trong models.json#
Ví dụ minh hoạ: ~/.pi/agent/models.json
{
"providers": {
"ollama": {
"baseUrl": "http://localhost:11434/v1",
"api": "openai-completions",
"apiKey": "ollama",
"compat": { "supportsDeveloperRole": false, "supportsReasoningEffort": false },
"models": [{ "id": "qwen2.5-coder:7b" }]
}
}
}docs/models.md, thêm hai cờ compat mà README khuyên cho Ollama.- Nạp và kiểm tra
ModelConfig.load()kiểm tra file bằng lược đồ đã biên dịch.ollamakhông phải id dựng sẵn, nên nó là một provider mới. - Ghép
composeProvider("ollama"): không có provider gốc, có cấu hình, nêncomposeModelProvider(..., base = undefined, ...).applyModelsJson()trên danh sách gốc rỗng tạo đúng một mô hình bằngmodelFromJson(): API vàbaseUrllấy từ cấp provider, cửa sổ 128.000 token, output 16.384, giá 0, compat gộp từ cấp provider. - Xác thực
composeApiKeyAuth()không có xác thực kế thừa, có khoá cấu hình"ollama": một chuỗi thường, phân giải thành chính nó.check()báo đã cấu hình, nên mô hình hiện trong/model. BỏapiKeyđi thì provider không có cách xác thực nào trả về kết quả, và mô hình biến mất khỏi danh sách dùng được. - Phân giải tên
--model ollama/qwen2.5-coder:7b: id chứa dấu hai chấm, nhưng hậu tố:<mức suy nghĩ>chỉ được xét khi cả chuỗi không khớp mô hình nào, và7bcũng không phải một mức suy nghĩ, nên id được giữ nguyên. - Chọn adapter
streamWith(): không có stream của extension, không có provider gốc, nên lấyopenai-completionstừ registry toàn cục của/compat. - Request
detectCompat()không nhận ra provider hay host nên trả mặc định của OpenAI;model.compattắt vaidevelopervàreasoning_effort. Header chỉ cóUser-Agent(host không phải gateway nào cần attribution) vàAuthorization: Bearer ollama, không có session affinity. POST tớihttp://localhost:11434/v1/chat/completions. Chi phí bằng 0 vì giá bằng 0.
So với llama.cpp, ba khác biệt nằm ở tầng provider chứ không ở tầng API: llama.cpp có danh sách mô hình động đọc từ router, baseUrl đến từ bước phân giải xác thực thay vì từ catalog, và cờ compat được đặt từ những gì router báo về chat template. Còn API và adapter thì giống hệt.
Quyết định thiết kế#
Provider và API là hai trục riêng. Ở v1.4.0 có 42 provider dựng sẵn nhưng chỉ mười API chat. Nếu provider và giao thức là một, mỗi endpoint mới sẽ đòi một adapter mới, mà một adapter dài một đến hai nghìn dòng và phải được kiểm thử riêng. Tách hai trục ra, một endpoint "tương thích OpenAI" hay "tương thích Anthropic" chỉ còn là một factory mười lăm dòng cộng vài cờ compat, và một provider có thể trộn giao thức khi hãng phía sau dùng giao thức khác nhau cho các họ mô hình (Copilot, OpenCode, Fireworks, OpenRouter). README tóm lại đúng như vậy: provider là đơn vị runtime sở hữu catalog, xác thực và hành vi stream, còn API là giao thức dùng chung (README). Cái giá là số cờ compat phình ra; pi-ai kìm nó bằng ba cách: detectCompat() tự đoán theo provider và host, bộ sinh catalog ghi sẵn cờ đã kiểm chứng cho từng mô hình, và quy tắc chỉ bật cờ cho khác biệt đã kiểm chứng.
SDK nạp lười. pi-ai phụ thuộc vào SDK của Anthropic, OpenAI, Google và AWS, cộng các flow OAuth cần node:http. Phần lớn ứng dụng (một Worker, một trang web, một host của Pi Durable) chỉ dùng một hai provider. Lời giải là factory chỉ import lazy wrapper, entry ./models không kéo TypeBox hay catalog, và module chỉ chạy trên Node được nạp qua specifier là biến (README). Phương án bị loại là import tĩnh mọi SDK, đơn giản hơn nhưng buộc mọi bundle mang theo AWS SDK. Cái giá có hai phần: request đầu tiên của mỗi API chịu thêm thời gian nạp module, và bản đóng gói đơn file phải tự đăng ký những module mà bundler không nhìn thấy.
Credential phân giải lúc gửi request. Không provider nào giữ credential từ lúc được tạo. Lý do nằm rải rác khắp chương này: token Copilot sống ngắn, khoá của Meta sống một ngày, một tiến trình Pi khác có thể vừa làm mới token, khoá dạng !lệnh có thể đã xoay vòng, credential của ECS hay IRSA tự đổi, và Cloudflare cần account id của đúng request. Phân giải ở mỗi request, dưới khoá của kho credential, xử lý được tất cả những trường hợp đó mà không có cơ chế riêng nào. Nó cũng cho phép một Models phục vụ nhiều host (Pi Durable nhận cả một hàm trả về Models). Cái giá là mỗi request phải đọc kho credential; AuthStorage giảm nó bằng cách chỉ đọc lại file khi revision đổi.
Không chép credential của môi trường. Với AWS và Google, Pi không bao giờ đọc rồi lưu credential; resolve() chỉ báo "đã cấu hình" và SDK tự đi chuỗi credential của nó. Một bản chép sẽ cũ đi khi vai trò được xoay vòng, và sẽ đưa bí mật của đám mây vào auth.json.
Catalog vừa sinh sẵn vừa làm mới được. Catalog được sinh lúc build từ models.dev cùng danh sách của OpenRouter và Vercel, cộng các sửa tay theo từng hãng, nên id mô hình có kiểu chính xác và Pi chạy được khi ngoại tuyến. Nhưng mô hình mới ra mỗi tuần, nên trên đó có bốn lớp làm mới: lớp phủ pi.dev (chỉ khi mới hơn dữ liệu đóng gói), danh sách theo khoá của OpenRouter, catalog động của provider như Radius, llama.cpp, Copilot, và cuối cùng là đường lùi "Using custom model id" cho id chưa ai biết. Phương án chỉ dùng catalog từ xa bị loại vì Pi phải khởi động được khi không có mạng (PI_OFFLINE, chế độ -p).
Đăng nhập bằng gói thuê bao và cái giá của nó. Một số flow đóng vai ứng dụng chính thức của hãng: OAuth của Anthropic gửi danh tính của Claude Code (chương pi-ai), mô hình Copilot mang sẵn header của VS Code Copilot Chat trong catalog (generate-models.ts:163), Codex gửi originator. Điều đó nghĩa là hãng có thể đổi luật bất cứ lúc nào, và mức sử dụng được tính theo quy định của gói. Code nói thẳng một hệ quả: khi phiên dùng xác thực thuê bao của Anthropic, chế độ interactive cảnh báo rằng dùng qua harness bên thứ ba bị tính vào "extra usage" theo token, không theo hạn mức của gói Claude (interactive-mode.ts:323), tắt được bằng warnings.anthropicExtraUsage (docs/settings.md:172). Cờ isSubscription của OAuthAuth cho footer hiện chi phí kèm nhãn (sub), và Kimi For Coding được coi là thuê bao ngay cả khi dùng khoá (footer.ts:189). OpenAI Codex được giữ lại với nhãn "legacy", vì Sign in with ChatGPT trên provider openai đã thay nó (README).
Cạm bẫy và trường hợp biên#
- Cùng một mô hình, khác provider, khác giá và giới hạn. DeepSeek V4 Pro trên Azure có giá riêng của Azure và bộ cờ compat riêng (không vai
developer, không tham số cache) (generate-models.ts:3357); mô hình GPT trên Azure có cửa sổ ngữ cảnh khác OpenAI (generate-models.ts:3331); giới hạn của OpenRouter phụ thuộc vào khoá. Chi phí trong footer luôn là giá catalog của đường đang dùng. provider/idchỉ là gợi ý.--model moonshotai/kimi-k2.6có thể chạy qua OpenRouter nếu chỉ OpenRouter có credential, như đã thấy trong trace. Muốn chắc chắn, dùng--provider.- Biến môi trường dùng chung, credential thì không.
MOONSHOT_API_KEYlàm cảmoonshotailẫnmoonshotai-cnhiện là đã cấu hình; đó là do bảng biến môi trường, không có nghĩa khoá dùng được ở cả hai endpoint. Ngược lại,/loginlưu credential theo id, nên đăng nhậpqwen-token-plankhông cấu hìnhqwen-token-plan-individual(README). - Credential đã lưu thắng biến môi trường. Một khoá cũ trong
auth.jsonthắng khoá mới vừaexport./logoutchỉ xoá credential đã lưu; nó không gỡ biến môi trường, không gỡapiKeytrongmodels.json, không thu hồi credential ở phía hãng (docs/providers.md:16). Hệ quả lạ nhất: đã đăng nhậpopenaibằng ChatGPT thì classifier GPT-6 Luna biến mất dùOPENAI_API_KEYcó đặt, vì OAuth đã lưu sở hữu provider (docs/models.md:157). - Lệnh trong
auth.jsonvàmodels.jsoncư xử khác nhau. Lệnh củaauth.jsonchạy một lần và cache cả kết quả lỗi cho tới khi khởi động lại; lệnh củamodels.jsonchạy ở mỗi request, nên một lệnh chậm làm chậm mọi request (giới hạn 10 giây). - Máy chủ "tương thích OpenAI" lạ nhận mặc định của OpenAI.
store: false, vaideveloper,reasoning_effortvàmax_completion_tokenscó thể bị từ chối. Bật đúng cờ đã kiểm chứng, ở cấp provider hoặc cấp mô hình. - Nhận diện theo host không đồng đều. Một provider tự đặt id trỏ vào
openrouter.ainhậnthinkingFormat,x-session-idvà attribution của OpenRouter vì các phép nhận diện đó xét cả host, nhưng không nhậncacheControlFormat: "anthropic"cho mô hìnhanthropic/*, vì phép đó chỉ xétprovider === "openrouter". Đặt cờ đó tay trongcompat. - Khoá giả là cần thiết. Provider không có cách xác thực nào trả kết quả thì mô hình của nó không hiện ra, nên máy chủ cục bộ không cần khoá vẫn phải có
"apiKey": "ollama"; llama.cpp tự dùng"local". - Id chưa có trong catalog vẫn chạy, với siêu dữ liệu sai. Đường lùi "Using custom model id" chép cửa sổ ngữ cảnh, giá và compat của mô hình mặc định của provider. Compaction và tính tiền sẽ dựa trên các số đó; khai báo mô hình trong
models.jsonnếu cần số đúng. - Cổng của callback OAuth. Sign in with ChatGPT và Codex dùng cổng 1455, trùng với Codex CLI; ChatGPT báo lỗi khi cổng bận, Codex lùi về dán URL. Chạy trong container thì đặt
PI_OAUTH_CALLBACK_HOSTvà chuyển tiếp cổng, hoặc dùng cách dán mã hay device code. - Thiếu cấu hình nền tảng chỉ lộ ra lúc gửi. Mô hình Azure có
baseUrlrỗng: thiếuAZURE_OPENAI_BASE_URLlẫnAZURE_OPENAI_RESOURCE_NAMEthì request kết thúc bằng một sự kiệnerror. ADC của Vertex cần cả project lẫn location, còn khoáGOOGLE_CLOUD_API_KEYthì không. - Proxy. Chỉ proxy HTTP và HTTPS được hỗ trợ, SOCKS và PAC bị từ chối;
httpProxytrong settings không ghi đè biến môi trường đã có. - Changelog là nơi đọc các lỗi đã gặp. Ba lần sửa placeholder của Cloudflare, bốn lần làm chắc việc làm mới OAuth, đổi API của Kimi For Coding sang Completions ở 1.3.0 (chương pi-ai): phần lớn hành vi tinh tế trong chương này có một mục changelog giải thích vì sao.
Đọc code ở đâu#
Thứ tự đề nghị: bắt đầu từ tài liệu người dùng để biết có những đường nào, rồi đi từ pi-ai (provider, xác thực, flow) lên coding agent (cấu hình, ghép, extension). Đường dẫn tính từ gốc repo.
| # | File | Đọc để hiểu |
|---|---|---|
| 1 | docs/providers.md, docs/models.md, docs/models-configuration.md | Mọi cách kết nối từ phía người dùng, thứ tự credential, mọi trường của models.json. |
| 2 | providers/all.ts | Danh sách 42 provider dựng sẵn và các hàm đọc catalog có kiểu. |
| 3 | groq.ts, opencode.ts, openrouter.ts, radius.ts | Bốn khuôn mẫu factory: tối giản, nhiều API, catalog theo khoá, viết tay. |
| 4 | src/models.ts | CreateProviderOptions, createProvider(), getAuth(), applyAuth(), checkProviderAuth(). |
| 5 | auth/types.ts, auth/resolve.ts, auth/helpers.ts, auth/context.ts | Hợp đồng xác thực, thứ tự phân giải, envApiKeyAuth(), lazyOAuth(), AuthContext. |
| 6 | callback-server.ts, device-code.ts, rồi anthropic.ts, github-copilot.ts, meta.ts | Khối dựng của OAuth, rồi ba flow tiêu biểu: trình duyệt, đổi token, xin khoá. |
| 7 | amazon-bedrock.ts, bedrock-converse-stream.ts:155, google-vertex.ts, azure.ts, azure-openai-config.ts | Credential của nền tảng đám mây, vùng, endpoint, deployment. |
| 8 | cloudflare-auth.ts, cloudflare-stream.ts, cloudflare-ai-binding.ts | Placeholder theo tài khoản, header của gateway, fetch qua binding. |
| 9 | api/pi-messages.ts | Giao thức riêng của Pi, gọn nhất trong các adapter. |
| 10 | api/lazy.ts, bedrock-converse-stream.lazy.ts, auth/oauth/load.ts | Bốn cách nạp lười. |
| 11 | utils/identity.ts, utils/node-http-proxy.ts, utils/provider-env.ts | Attribution theo gateway, proxy ngoài fetch, biến môi trường theo provider. |
| 12 | scripts/generate-models.ts, scripts/openrouter-catalog.ts | Luật chọn API theo mô hình cho các provider nhiều API, cờ compat sinh sẵn. |
| 13 | core/model-runtime.ts | create(), composeProvider(), prepareRequest(), getAuth(), đăng ký provider của extension. |
| 14 | core/provider-composer.ts, core/model-config.ts, core/resolve-config-value.ts | Ghép models.json và extension lên provider gốc; cú pháp $VAR và !lệnh. |
| 15 | core/auth-storage.ts, core/runtime-credentials.ts, cli/credential-print.ts | auth.json với khoá file, --api-key, pi auth. |
| 16 | core/remote-catalog-provider.ts, core/models-store.ts | Lớp phủ pi.dev, catalog theo khoá, models-store.json. |
| 17 | extensions/llama/provider.ts, extensions/llama/index.ts | Một native provider hoàn chỉnh có catalog động và classifier. |
| 18 | core/model-resolver.ts | Cách --model chọn provider khi cùng một id có ở nhiều nơi. |
| 19 | core/http-dispatcher.ts, core/client-identity.ts | Proxy và timeout toàn cục, attribution theo telemetry. |
| 20 | custom-provider-gitlab-duo, custom-provider-anthropic, docs/custom-provider.md | Provider của extension: ủy quyền cho adapter có sẵn và stream tự viết. |
| 21 | durable/README.md, harness/generation.ts | Host của Pi Durable chọn provider; ModelRef phân giải ở mỗi lượt. |
Tóm tắt#
- Mỗi request trả lời ba câu hỏi độc lập: provider nào phục vụ, nói API nào, xác thực bằng gì. Ở v1.4.0 có 42 provider dựng sẵn, mười API chat, một API ảnh, bốn API classifier; provider và API ghép nhiều với nhiều.
- Provider phần lớn là một factory mười lăm dòng qua
createProvider(): catalog sinh tự động,ProviderAuth, lazy wrapper của API. Provider nhiều API chọn adapter theomodel.api; wrapper quanhProviderStreamsbiến đổi request trước khi gửi. - pi-ai phân giải credential ở mỗi request: khoá tường minh, rồi credential đã lưu (sở hữu provider, OAuth làm mới dưới khoá), rồi môi trường. Coding agent thêm
--api-key,auth.jsonvàapiKeycủamodels.jsonvào đúng chuỗi đó. - Chín flow OAuth dùng ba khối chung (PKCE, máy chủ callback đua với dán mã, hỏi vòng device code), và tách
refresh()khỏitoAuth()đủ rộng để chứa đổi token của Copilot, xin khoá của Meta, khoá vĩnh viễn của OpenRouter. - Nền tảng đám mây không bị chép credential:
resolve()chỉ báo đã cấu hình, SDK của AWS và Google tự lấy credential; Azure và Cloudflare dựng URL theo tài khoản lúc gửi; Anthropic có workload identity federation qua SDK. - Gateway là provider có catalog đa hãng cộng vài việc riêng: chọn API theo mô hình, catalog theo khoá, header attribution và định tuyến theo phiên. Cùng một mô hình gốc có nhiều đường, mỗi đường một giá, một giới hạn, một API.
- Mô hình cục bộ đi qua extension
llama.cpp(catalog động,baseUrltừ xác thực) hoặc quamodels.jsonvới cờ compat đã kiểm chứng; extension đăng ký provider hoàn chỉnh hoặcProviderConfig, và nên ủy quyền phần stream cho adapter có sẵn. - Pi Durable để host tự chọn provider bằng
createModels()vàsetProvider(), và chỉ lưu tham chiếu{ provider, modelId }, nên đổi cấu hình provider không phải sửa dữ liệu.
6pi-agent-core: vòng lặp agent#
Vai trò của pi-agent-core#
pi-ai (chương pi-ai) làm đúng một việc: gửi một context tới một mô hình (model) và trả về một luồng (stream) gồm các sự kiện (event) của một câu trả lời. Một agent cần nhiều hơn thế. Khi câu trả lời chứa lời gọi tool, phải có ai đó kiểm tra đối số, chạy tool, gói kết quả thành message, nối vào transcript rồi gọi mô hình lần nữa, cứ thế cho tới khi mô hình trả lời mà không gọi tool. Trong lúc đó giao diện cần biết từng bước đang diễn ra, và người dùng vẫn có thể gõ thêm tin nhắn. Phần “cứ thế cho tới khi” là vòng lặp agent (agent loop), và nó nằm trong gói @earendil-works/pi-agent-core, thư mục packages/agent.
README mô tả gói là “Stateful agent with tool execution and event streaming. Built on @earendil-works/pi-ai” (README.md:3). Gói rất nhỏ: sáu file trong src/, tổng 2.539 dòng. agent-loop.ts (959 dòng) là vòng lặp, agent.ts (623) là lớp bọc có state, types.ts (539) chứa toàn bộ data model, proxy.ts (393) và stream-fn.ts (20) là phần vận chuyển. Gói cung cấp hai tầng API:
- Hàm cấp thấp
agentLoop()vàagentLoopContinue(): không giữ state, nhận context, cấu hình, mộtAbortSignalvà một hàm stream, trả về luồngAgentEvent. - Lớp
Agent: bọc vòng lặp, sở hữu transcript, hai hàng đợi tin nhắn (steer và follow-up), danh sách subscriber vàAbortControllercủa lần chạy hiện tại.
Trong monorepo ở v1.4.0, chỉ pi-coding-agent phụ thuộc vào gói này; Pi Durable có vòng lặp riêng dựng thẳng trên pi-ai (xem Bản đồ monorepo và Pi Durable). Từ 1.0.0 gói chỉ còn Agent, vòng lặp, proxy stream và các kiểu của chúng; harness thử nghiệm cũ (session, compaction, skills, telemetry...) đã bị gỡ ra (CHANGELOG.md:35).
Ranh giới trách nhiệm vì thế rất rõ. agent-core không biết provider (nhà cung cấp) nào, vì hàm stream được truyền từ ngoài vào; nó không ghi file session, không làm compaction (nén ngữ cảnh), không dựng system prompt và không hỏi quyền chạy tool. Nó chỉ biết transcript, bộ tool và các hook. Coding agent lấp các chỗ trống đó bằng cách cấu hình một Agent duy nhất trong createAgentSession():
const agent = new Agent({ initialState: { systemPrompt: "", model, thinkingLevel, tools: [], messages: existingSession.messages, }, convertToLlm: convertToLlmWithBlockImages, 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); }, onPayload: transformProviderPayload, onResponse: handleProviderResponse, onProviderStreamEvent: handleProviderStreamEvent, sessionId: sessionManager.getSessionId(), transformContext: async (messages) => { const runner = extensionRunnerRef.current; if (!runner) return messages; return runner.emitContext(messages); }, steeringMode: settingsManager.getSteeringMode(), followUpMode: settingsManager.getFollowUpMode(), transport: settingsManager.getTransport(), thinkingBudgets: settingsManager.getThinkingBudgets(), maxRetryDelayMs: settingsManager.getProviderRetrySettings().maxRetryDelayMs,});streamFn là chỗ duy nhất agent-core chạm tới provider; ở đây nó đi qua ModelRuntime của coding agent và bộ làm ấm cache (cache warmer).Phần còn lại do AgentSession gắn vào sau khi tạo: một subscriber nhận mọi sự kiện để lưu session, chạy extension, tự compaction và thử lại (agent-session.ts:511–517), cặp hook beforeToolCall/afterToolCall (:652), prepareRequest để thay context bằng phép chiếu chuẩn của session (:787), finishTurn (:886) và prepareNextTurnWithContext để compaction và cập nhật system prompt giữa các lượt (:898). Chương này giải thích các hook đó có nghĩa gì với vòng lặp; nội dung bên trong chúng thuộc về chương AgentSession.
waitForIdle().(model, context, options) trả về luồng sự kiện: Models.streamSimple, streamProxy, hoặc hàm tự viết.AssistantMessageEvent.Mô hình tư duy: lần chạy, lượt và hai vòng lặp#
Phần còn lại của chương dùng các từ sau với nghĩa chính xác như trong code:
- Lần chạy (run)
- Một lần gọi
prompt()hoặccontinue(), mở bằng sự kiệnagent_startvà đóng bằngagent_end. Một lần chạy có thể gồm nhiều lượt. - Lượt (turn)
- Một câu trả lời của mô hình cộng với các tool mà câu trả lời đó gọi, mở bằng
turn_start, đóng bằngturn_end(types.ts:520). - Batch tool
- Mọi lời gọi tool nằm trong một assistant message. Mô hình có thể gọi nhiều tool cùng lúc; vòng lặp chạy xong cả batch rồi mới gọi lại mô hình.
- Transcript
- Mảng
AgentMessage[]theo thời gian: system, user, assistant, toolResult và các message tuỳ biến của ứng dụng. - Tin nhắn steer
- Tin nhắn chen vào lượt đang chạy. Nó không cắt ngang tool nào: nó được chèn sau khi batch tool của lượt hiện tại xong, trước lần gọi mô hình kế tiếp.
- Follow-up
- Tin nhắn xếp hàng sau, chỉ được chèn khi agent lẽ ra đã dừng.
Cấu trúc điều khiển là hai vòng while lồng nhau trong runLoop(). Vòng trong chạy các lượt chừng nào mô hình còn gọi tool hoặc còn tin nhắn chờ. Vòng ngoài chỉ có một nhiệm vụ: khi vòng trong muốn dừng, hỏi hàng đợi follow-up; có tin thì quay lại vòng trong.
prompt() hoặc continue()
│ agent_start, turn_start, message_start/end cho các message của prompt
│
├─ vòng ngoài: while (true)
│ │
│ ├─ vòng trong: while (còn tool call || còn tin nhắn chờ)
│ │ 1. từ lượt thứ hai: prepareNextTurn, hỏi lại steer nếu chưa có, turn_start
│ │ 2. chèn tin nhắn chờ (steer hoặc follow-up) message_start / message_end
│ │ 3. prepareRequest
│ │ 4. gọi mô hình qua StreamFn message_start / update... / end
│ │ stopReason "error" hoặc "aborted" → turn_end, agent_end, thoát
│ │ 5. chạy batch tool tool_execution_*, toolResult
│ │ 6. finishTurn turn_end
│ │ {action: "end"} → agent_end, thoát
│ │ 7. hỏi hàng đợi steer
│ │
│ └─ vòng trong muốn dừng: hỏi hàng đợi follow-up
│ có tin → thành tin nhắn chờ, quay lại vòng trong
│ không tin → thoát vòng ngoài (trừ khi finishTurn đã đòi {action: "continue"})
│
└ agent_end
runLoop(). Mỗi dòng bên phải là các sự kiện phát ra ở bước đó.Data model: AgentMessage, AgentContext và AgentState#
Vấn đề. Transcript của một ứng dụng thực tế chứa nhiều thứ hơn những gì mô hình hiểu. Coding agent lưu kết quả của lệnh !ls mà người dùng tự gõ, bản tóm tắt sau compaction, tin nhắn do extension chèn vào. Nếu kiểu của transcript chỉ là Message của pi-ai (system, user, assistant, toolResult, xem ai/types.ts:652), các thứ đó phải sống ở chỗ khác và transcript không còn là nguồn sự thật duy nhất.
Giải pháp. agent-core làm việc với AgentMessage, là hợp của Message với một interface rỗng mà ứng dụng tự mở rộng:
/** * Extensible interface for custom app messages. * Apps can extend via declaration merging: * * @example * ```typescript * declare module "@mariozechner/agent" { * interface CustomAgentMessages { * artifact: ArtifactMessage; * notification: NotificationMessage; * } * } * ``` */export interface CustomAgentMessages { // Empty by default - apps extend via declaration merging}/** * AgentMessage: Union of LLM messages + custom messages. * This abstraction allows apps to add custom message types while maintaining * type safety and compatibility with the base LLM messages. */export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];@mariozechner/agent; khi mở rộng thật phải dùng tên module hiện tại, @earendil-works/pi-agent-core.Mặc định CustomAgentMessages rỗng, nên keyof CustomAgentMessages là never và AgentMessage chỉ là Message. Khi một ứng dụng viết declare module "@earendil-works/pi-agent-core" { interface CustomAgentMessages { ... } }, TypeScript gộp (declaration merging) khai báo đó vào interface gốc, và mỗi thuộc tính thêm một thành viên vào hợp. Tên khoá chỉ là nhãn; điều quan trọng là kiểu giá trị, và trên thực tế mỗi kiểu tuỳ biến cần một role riêng vì cả vòng lặp lẫn convertToLlm phân biệt message bằng role. Coding agent mở rộng đúng theo cách đó với bốn loại message:
// Extend CustomAgentMessages via declaration mergingdeclare module "@earendil-works/pi-agent-core" { interface CustomAgentMessages { bashExecution: BashExecutionMessage; custom: CustomMessage; branchSummary: BranchSummaryMessage; compactionSummary: CompactionSummaryMessage; }}Mô hình không bao giờ thấy các role này. Trước mỗi lần gọi mô hình, vòng lặp gọi convertToLlm để biến AgentMessage[] thành Message[]. Bản của coding agent chuyển các loại tuỳ biến thành message user và bỏ qua lệnh bash có tiền tố !!:
export function convertToLlm(messages: AgentMessage[]): Message[] { return messages .map((m): Message | undefined => { switch (m.role) { case "bashExecution": // Skip messages excluded from context (!! prefix) if (m.excludeFromContext) { return undefined; } return { role: "user", content: [{ type: "text", text: bashExecutionToText(m) }], timestamp: m.timestamp, }; case "system": case "user": case "assistant": case "toolResult": return m; default: // biome-ignore lint/correctness/noSwitchDeclarations: fine const _exhaustiveCheck: never = m; return undefined; } }) .filter((m) => m !== undefined);custom, branchSummary, compactionSummary ở giữa cũng trả về message user; bản tóm tắt được bọc trong thẻ <summary>.Nếu ứng dụng không truyền convertToLlm, lớp Agent dùng một bộ lọc giữ lại đúng bốn role chuẩn (agent.ts:39–47).
Thứ vòng lặp nhận vào là một AgentContext: transcript và các tool chạy được.
/** Context snapshot passed into the low-level agent loop. */export interface AgentContext { /** Transcript visible to the model. */ messages: AgentMessage[]; /** Tools available for execution in this run. */ tools?: AgentTool<any>[];}Lưu ý tools ở đây là các AgentTool có hàm execute, tức những gì runtime chạy được. Những gì mô hình được phép gọi lại được khai báo trong các system message của transcript; mục declareToolChanges giải thích vì sao hai thứ này tách nhau.
Lớp Agent phơi state công khai qua AgentState:
export interface AgentState { /** * Current system prompt, replayed from the transcript's system messages. * * Read-only: to change the prompt, append a system message with `content` or `sections`. * In `initialState`, this seeds the leading system message. */ readonly systemPrompt: string; /** Active model used for future turns. */ model: Model<any>; /** Requested reasoning level for future turns. */ thinkingLevel: ThinkingLevel; /** * Executable tools. Assigning a new array copies the top-level array. * * Differences from the tools declared in the transcript are announced to the model * with a system message before the next request. */ set tools(tools: AgentTool<any>[]); get tools(): AgentTool<any>[]; /** * Conversation transcript. Assigning a new array copies the top-level array. * * System messages in the transcript carry the prompt and tool declarations. */ set messages(messages: AgentMessage[]); get messages(): AgentMessage[]; /** * True while the agent is processing a prompt or continuation. * * This remains true until awaited `agent_end` listeners settle. */ readonly isStreaming: boolean; /** Partial assistant message for the current streamed response, if any. */ readonly streamingMessage?: AgentMessage; /** Tool call ids currently executing. */ readonly pendingToolCalls: ReadonlySet<string>; /** Error message from the most recent failed or aborted assistant turn, if any. */ readonly errorMessage?: string;}systemPromptchỉ đọc. Nó được tính lại bằng cách phát lại (replay) mọi system message trong transcript (getCurrentSystemPrompt). Muốn đổi prompt giữa hội thoại thì nối thêm một system message cócontent(thêm chỉ dẫn) hoặcsections(thay một mục theo tên).modelvà mức suy nghĩ (thinking level)thinkingLevelchỉ được đọc khi một lần chạy bắt đầu, lúcAgentdựng cấu hình cho vòng lặp. Muốn giá trị mới có hiệu lực ngay từ lượt kế tiếp của lần chạy đang diễn ra, một hook nhưprepareNextTurnphải trả chúng về;AgentSessionlàm đúng như vậy.toolsvàmessageslà accessor: gán một mảng mới thì mảng cấp ngoài cùng được sao chép (agent.ts:95–106), cònpush()vào mảng trả về thì sửa thẳng state hiện tại.isStreaming,streamingMessage,pendingToolCalls,errorMessagechỉ đọc với bên ngoài; chúng do chính các sự kiện của vòng lặp cập nhật (xem lớp Agent).
Khi tạo Agent với systemPrompt và tools, hai thứ này trở thành system message đầu tiên của transcript, mang toolsAdded và timestamp: 0 (agent.ts:86–87, transcript.ts:10–23). Sau một lượt có gọi tool, transcript trông như sau:
Ví dụ minh hoạ: agent.state.messages sau một prompt có một lời gọi tool
[
{ role: "system", content: "You are helpful.",
toolsAdded: [{ name: "read", description: "...", parameters: {...} }], timestamp: 0 },
{ role: "user", content: [{ type: "text", text: "Đọc a.txt" }], timestamp: 1761100000000 },
{ role: "assistant", stopReason: "toolUse", thinkingLevel: "off",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "a.txt" } }],
api: "...", provider: "...", model: "...", usage: {...}, timestamp: 1761100000100 },
{ role: "toolResult", toolCallId: "call_1", toolName: "read", isError: false,
content: [{ type: "text", text: "nội dung a.txt" }], details: {}, durationMs: 2,
timestamp: 1761100001200 },
{ role: "assistant", stopReason: "stop", content: [{ type: "text", text: "a.txt chứa ..." }], ... }
]
thinkingLevel trên assistant message là mức suy nghĩ mà vòng lặp đã yêu cầu.Data model: AgentTool và kết quả tool#
AgentTool mở rộng Tool của pi-ai (name, description, parameters là một schema TypeBox) bằng những gì runtime cần để chạy tool:
/** Tool definition used by the agent runtime. */export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> { /** Human-readable label for UI display. */ label: string; /** * Optional compatibility shim for raw tool-call arguments before schema validation. * Must return an object that matches `TParameters`. */ prepareArguments?: (args: unknown) => Static<TParameters>; /** * JSON Schema of `structuredContent` in successful results. Tools that declare it should always * set `structuredContent`. */ outputSchema?: TSchema; /** * Execute the tool call. Throw on failure, or return a result with `isError: true`; do not only * describe the failure in `content`. */ execute: ( toolCallId: string, params: Static<TParameters>, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback<TDetails>, ) => Promise<AgentToolResult<TDetails>>; /** Recovery policy for an effect whose durable intent exists but whose outcome is unknown. */ replay?: "never" | "safe"; /** * Per-tool execution mode override. * - "sequential": this tool must execute one at a time with other tool calls. * - "parallel": this tool can execute concurrently with other tool calls. * * If omitted, the default execution mode applies. */ executionMode?: ToolExecutionMode;}labellà tên hiển thị cho giao diện, không gửi cho mô hình.prepareArgumentslà lớp tương thích chạy trước khi kiểm tra schema, ví dụ để nhận đối số kiểu cũ từ một session được mở lại sau khi schema đã đổi (CHANGELOG 0.64.0, CHANGELOG.md:481).execute(toolCallId, params, signal, onUpdate)nhận đối số đã kiểm tra, tín hiệu huỷ của lần chạy, và một callback để phát kết quả từng phần.executionModecho phép một tool ép cả batch chạy tuần tự (xem thực thi tool).replaymô tả chính sách phục hồi cho một hiệu ứng đã có ý định bền (durable intent) nhưng chưa rõ kết quả. Vòng lặp ở v1.4.0 không đọc trường này, và coding agent cũng không; Pi Durable có khái niệm tương tự trong harness riêng.
Kết quả của một tool tách hai phần cho hai người đọc khác nhau:
/** Final or partial result produced by a tool. */export interface AgentToolResult<T = JsonValue | undefined> { /** Text or image content returned to the model. */ content: (TextContent | ImageContent)[]; /** Arbitrary structured details for logs or UI rendering. */ details: T; /** * Machine-readable result matching the tool's `outputSchema`, for programmatic callers. Not sent * to the model; `content` remains the model-facing result. */ structuredContent?: JsonValue; /** Usage from the final tool execution itself, if available. Not used for main LLM context accounting. */ usage?: Usage; /** * Report a failure without throwing. The model sees `content` as an error result, like a thrown * error, but `details` and `structuredContent` are kept for the UI and programmatic callers. */ isError?: boolean; /** * Hint that the agent should stop after the current tool batch. * Early termination only happens when every finalized tool result in the batch sets this to true. */ terminate?: boolean;}content là thứ mô hình đọc. details là dữ liệu có cấu trúc tuỳ ý cho log và giao diện, không bao giờ gửi cho mô hình. Ví dụ tool read của coding agent trả văn bản trong content, còn thông tin cắt bớt có cấu trúc thì nằm trong details: { truncation } (read.ts:40–42) để giao diện vẽ ghi chú riêng. Bài blog đầu tiên của Mario gọi đây là “structured split tool results”: “splitting tool results into a portion handed to the LLM and a portion for UI display” (What I learned building an opinionated and minimal coding agent).
Các trường còn lại phục vụ những người khác. structuredContent dành cho caller lập trình (ví dụ một script codemode gọi tool lồng nhau), khớp với outputSchema. usage là lượng token mà chính việc chạy tool tiêu thụ, không tính vào ngữ cảnh chính. isError báo lỗi mà không cần throw: mô hình vẫn thấy một kết quả lỗi, còn details được giữ lại. terminate là gợi ý dừng sau batch hiện tại. Khi kết quả được đóng gói thành message toolResult cho transcript, chỉ content, details, usage, isError và durationMs được giữ; terminate và structuredContent chỉ sống trong sự kiện tool_execution_end:
function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage { return { role: "toolResult", toolCallId: finalized.toolCall.id, toolName: finalized.toolCall.name, // Untyped tools (JS extensions) can return results without content; normalize // so the null never enters session history or provider payloads. content: finalized.result.content ?? [], details: finalized.result.details, usage: finalized.result.usage, isError: finalized.isError, ...(finalized.durationMs === undefined ? {} : { durationMs: finalized.durationMs }), timestamp: Date.now(), };}Tool phát kết quả từng phần qua onUpdate, mỗi lần gọi thành một sự kiện tool_execution_update. Callback chỉ có hiệu lực trong lúc execute() còn chạy; gọi sau khi promise đã xong thì bị bỏ qua (types.ts:457–463), một sửa lỗi ở 0.79.2 để giao diện không nhận cập nhật cũ (CHANGELOG.md:295).
Data model: AgentLoopConfig và các hook#
Mọi hành vi tuỳ biến của vòng lặp đi qua một đối tượng cấu hình. AgentLoopConfig kế thừa SimpleStreamOptions của pi-ai (API key, reasoning, sessionId, transport, ...) nên chính nó cũng là options truyền cho hàm stream. Đây là các trường riêng của nó, đã lược chú thích:
export interface AgentLoopConfig extends SimpleStreamOptions { model: Model<any>; convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>; transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>; getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined; finishTurn?: FinishTurn; prepareRequest?: PrepareRequest; prepareNextTurn?: ( context: PrepareNextTurnContext, ) => AgentLoopTurnUpdate | undefined | Promise<AgentLoopTurnUpdate | undefined>; getSteeringMessages?: () => Promise<AgentMessage[]>; getFollowUpMessages?: () => Promise<AgentMessage[]>; toolExecution?: ToolExecutionMode; beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>; afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;}| Trường | Vòng lặp gọi khi nào | Trả về và hợp đồng |
|---|---|---|
convertToLlm (bắt buộc) | Trước mỗi lần gọi mô hình, sau transformContext | Message[]. Không được throw (types.ts:203) |
transformContext | Trước convertToLlm, trên AgentMessage[] | Transcript đã biến đổi, ví dụ cắt bớt hoặc chèn ngữ cảnh. Không được throw |
getApiKey | Mỗi lần gọi mô hình | Key hoặc undefined; hữu ích cho token OAuth hết hạn giữa lúc tool chạy lâu |
prepareRequest | Ngay trước mọi request hội thoại, kể cả lần đầu, sau khi tin nhắn chờ đã được chèn | Context, model, mức suy nghĩ thay thế cho request này và các request sau. Không hỏi hàng đợi |
finishTurn | Sau assistant message và mọi toolResult, trước turn_end | { action: "end" }, { action: "continue" } hoặc undefined |
prepareNextTurn | Sau turn_end, khi chắc chắn còn lượt tiếp, ngay trước lượt đó | Context, model, mức suy nghĩ, hoặc message cần nối thêm |
getSteeringMessages | Đầu lần chạy, sau mỗi lượt, và sau prepareNextTurn nếu lần hỏi trước rỗng | AgentMessage[], rỗng nếu không có gì. Không được throw |
getFollowUpMessages | Khi agent lẽ ra đã dừng | AgentMessage[]. Không được throw |
toolExecution | Mỗi batch tool | "parallel" (mặc định) hoặc "sequential" |
beforeToolCall | Sau tool_execution_start và sau khi đối số đã được kiểm tra | { block, reason, terminate } để chặn tool |
afterToolCall | Sau execute(), trước tool_execution_end | Ghi đè từng trường của kết quả (không gộp sâu) |
Hợp đồng “không được throw” có lý do cụ thể: một exception từ các hàm này cắt ngang vòng lặp cấp thấp mà không phát chuỗi sự kiện bình thường (types.ts:203–204). Lớp Agent có một lưới an toàn cho trường hợp này (xem dưới), còn người dùng agentLoop() trực tiếp thì không.
Ba hook điều khiển lịch trình có kiểu riêng:
/** Decision returned by {@link FinishTurn}. Returning undefined preserves normal scheduling. */export type AgentTurnDecision = { action: "continue" } | { action: "end" };/** * Called after a completed assistant turn and all of its tool-result messages, but before `turn_end`. * On a normal turn, `{ action: "continue" }` ensures one next provider request. Tool-result, steering, or * follow-up scheduling can satisfy that request and adds no extra request; otherwise the loop continues once * with the current context. Error and aborted responses remain hard exits. */export type FinishTurn = ( turn: AgentTurnContext, signal?: AbortSignal,) => AgentTurnDecision | void | Promise<AgentTurnDecision | undefined> | Promise<void>;/** Replacement runtime state used by the agent loop before starting another provider request. */export interface AgentLoopTurnUpdate { /** Context for the next provider request. */ context?: AgentContext; /** Messages to append before the next provider request, with normal lifecycle events. */ messages?: AgentMessage[]; /** Model for the next provider request. */ model?: Model<any>; /** Thinking level for the next provider request. */ thinkingLevel?: ThinkingLevel;}Ngữ nghĩa của { action: "continue" } tinh tế hơn tên gọi: nó bảo đảm ít nhất một request tiếp theo, chứ không thêm một request. Nếu lượt vừa rồi có tool call, hoặc có tin steer hay follow-up, thì request tự nhiên kế tiếp đã thoả yêu cầu; chỉ khi không có gì, vòng lặp mới chạy thêm đúng một lượt với context hiện tại (types.ts:149–154). Phản hồi lỗi hoặc bị huỷ luôn là lối ra cứng; quyết định của finishTurn khi đó bị bỏ qua. README cảnh báo: trả về continue vô điều kiện sẽ tạo vòng lặp vô tận, vì finishTurn chạy lại sau request kế tiếp (README.md:146).
Data model: AgentEvent#
Vòng lặp không trả về giá trị trung gian nào; mọi thứ nó làm đều được báo qua sự kiện:
/** * Events emitted by the Agent for UI updates. * * `agent_end` is the last event emitted for a run, but awaited `Agent.subscribe()` * listeners for that event are still part of run settlement. The agent becomes * idle only after those listeners finish. */export type AgentEvent = // Agent lifecycle | { type: "agent_start" } | { type: "agent_end"; messages: AgentMessage[] } // Turn lifecycle - a turn is one assistant response + any tool calls/results | { type: "turn_start" } | { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] } // Message lifecycle - emitted for system, user, assistant, and toolResult messages | { type: "message_start"; message: AgentMessage } // Only emitted for assistant messages during streaming | { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent } | { type: "message_end"; message: AgentMessage } // Tool execution lifecycle | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any } | { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any } | { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError: boolean; /** Milliseconds `execute()` took, measured with a monotonic clock; absent when the tool did not run. */ durationMs?: number; };- Lần chạy:
agent_startvàagent_end.agent_end.messageslà các message mới của lần chạy: vớiprompt()gồm cả message của prompt, vớicontinue()thì không gồm các message đã có (types.ts:142). - Lượt:
turn_startvàturn_end;turn_endmang assistant message và các toolResult của lượt. - Message:
message_start/message_endphát cho mọi message, kể cả system, user và toolResult.message_updatechỉ có với assistant message đang stream, kèmassistantMessageEventgốc của pi-ai (text_delta,toolcall_delta, ...). - Tool:
tool_execution_start,_update,_end._endmang toàn bộAgentToolResult(gồm cảstructuredContentvàterminate) vàdurationMsđo bằng đồng hồ đơn điệu, không tính thời gian chạy hook (CHANGELOG 1.1.0, CHANGELOG.md:17).
Hai chi tiết đáng nhớ. Message trong message_start và message_update của assistant là bản sao nông { ...partialMessage } chụp tại thời điểm đó, còn message_end mang đối tượng cuối cùng (agent-loop.ts:430, :464). Và agent_end chỉ có nghĩa là vòng lặp sẽ không phát thêm sự kiện nào; lớp Agent chỉ coi là rảnh sau khi các listener của chính sự kiện đó chạy xong (types.ts:509–515).
StreamFn và proxy: lớp vận chuyển#
package.json vẫn mô tả gói là “General-purpose agent with transport abstraction” (package.json:4), nhưng ở v1.4.0 lớp vận chuyển không còn là một interface hay một lớp nào. Ở 0.31.0, các interface ProviderTransport, AppTransport, AgentTransport bị xoá và thay bằng một tuỳ chọn duy nhất, streamFn (CHANGELOG.md:757). Cũng ở phiên bản đó, agentLoop() và agentLoopContinue() chuyển từ pi-ai sang gói này (CHANGELOG.md:770); vì vậy bài blog tháng 11/2025 của Mario còn mô tả vòng lặp dưới mục pi-ai, còn lớp Agent với “transport abstraction” thuộc pi-agent-core. Từ 0.31.0, “vận chuyển” chỉ là một kiểu hàm:
/** * Stream function used by the agent loop. `Models.streamSimple` satisfies * this shape. * * The loop passes a normalized transcript: the system prompt and tool * declarations are carried by the transcript's system messages, never by * `context.systemPrompt` or `context.tools`. * * Contract: * - Must not throw or return a rejected promise for request/model/runtime failures. * - Must return an AssistantMessageEventStream. * - Failures must be encoded in the returned stream via protocol events and a * final AssistantMessage with stopReason "error" or "aborted" and errorMessage. */export type StreamFn = ( model: Model<Api>, context: TranscriptContext, options?: SimpleStreamOptions,) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;Hợp đồng có ba điểm. Hàm nhận một TranscriptContext đã chuẩn hoá, trong đó system prompt và khai báo tool nằm trong các system message, không nằm ở context.systemPrompt hay context.tools. Hàm không được throw: mọi lỗi request, lỗi mô hình hay bị huỷ đều phải được mã hoá thành sự kiện trong luồng, kết thúc bằng một assistant message có stopReason là "error" hoặc "aborted". Và Models.streamSimple của pi-ai khớp với kiểu này, nên trường hợp thường gặp chỉ là streamFn: models.streamSimple.bind(models) (README.md:23–29).
Bản thân agent-core không import provider nào. Nếu caller bỏ qua streamFn (các consumer biên dịch từ phiên bản cũ có thể làm vậy, xem agent.ts:239), nó dùng một hàm mặc định do host cài:
let defaultStreamFn: StreamFn | undefined;/** * Configure the fallback used by Agent and low-level loops when callers omit streamFn. * * Hosts that provide a default model runtime can install its stream function here * without making pi-agent-core depend on a provider catalog or compatibility layer. */export function setDefaultStreamFn(streamFn: StreamFn | undefined): void { defaultStreamFn = streamFn;}export function getDefaultStreamFn(): StreamFn { if (!defaultStreamFn) { throw new Error("No default stream function configured. Pass streamFn explicitly or call setDefaultStreamFn()."); } return defaultStreamFn;}Coding agent cài streamSimple của pi-ai/compat làm mặc định ngay khi module sdk.ts được nạp, để extension nào tự tạo Agent mà quên streamFn vẫn chạy (sdk.ts:43–46). Cách sắp xếp này có chủ đích: ở 0.81.0, streamFn từng bị bắt buộc để pi-ai/compat và mọi provider dựng sẵn không lọt vào các bundle chỉ chọn vài provider (CHANGELOG.md:193); bản sửa sau đó đưa lại fallback nhưng do host cấu hình (:185).
streamProxy: khi mô hình nằm sau một server
Ứng dụng trình duyệt không nên giữ API key của provider. streamProxy() là một StreamFn gửi request tới server của chính ứng dụng; server giữ khoá, gọi provider, rồi stream sự kiện ngược lại. Để tiết kiệm băng thông, server bỏ trường partial (assistant message dựng dần) khỏi mỗi sự kiện, nên giao thức proxy là một phiên bản rút gọn của AssistantMessageEvent:
/** * Proxy event types - server sends these with partial field stripped to reduce bandwidth. */export type ProxyAssistantMessageEvent = | { type: "start" } | { type: "text_start"; contentIndex: number } | { type: "text_delta"; contentIndex: number; delta: string } | { type: "text_end"; contentIndex: number; contentSignature?: string } | { type: "thinking_start"; contentIndex: number } | { type: "thinking_delta"; contentIndex: number; delta: string } | { type: "thinking_end"; contentIndex: number; contentSignature?: string } | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string } | { type: "toolcall_delta"; contentIndex: number; delta: string } | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall } | { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; usage: AssistantMessage["usage"]; providerThinkingLevel?: string; } | { type: "error"; reason: Extract<StopReason, "aborted" | "error">; errorMessage?: string; usage: AssistantMessage["usage"]; providerThinkingLevel?: string; };try { const response = await fetch(`${options.proxyUrl}/api/stream`, { method: "POST", headers: { Authorization: `Bearer ${options.authToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ model, context, options: buildProxyRequestOptions(options), }), signal: options.signal, });Phía client đọc từng dòng data: {...} và dựng lại partial trong processProxyEvent(). Đoạn khó nhất là đối số tool: chúng tới dưới dạng các mảnh JSON chưa hoàn chỉnh, nên client cộng dồn chuỗi và dùng bộ đọc JSON từng phần của pi-ai sau mỗi mảnh:
case "toolcall_delta": { const content = partial.content[proxyEvent.contentIndex]; if (content?.type === "toolCall") { (content as any).partialJson += proxyEvent.delta; content.arguments = parseStreamingJson((content as any).partialJson) || {}; partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity return { type: "toolcall_delta", contentIndex: proxyEvent.contentIndex, delta: proxyEvent.delta, partial, }; } throw new Error("Received toolcall_delta for non-toolCall content");}streamProxy() giữ đúng hợp đồng không-throw của StreamFn: lỗi HTTP, lỗi mạng và abort đều thành một sự kiện error với stopReason tương ứng (proxy.ts:233–243). Nó cũng bắt một trường hợp dễ quên: server đóng kết nối sạch sẽ mà chưa gửi done hay error. Khi đó nó tự phát một sự kiện error với thông báo “Connection closed by proxy server before the response completed” (proxy.ts:219–230); không có kiểm tra này, vòng lặp sẽ chờ mãi một kết quả không bao giờ tới.
runLoop: vòng ngoài và vòng trong#
Hai cửa vào: agentLoop và agentLoopContinue
agentLoop() thêm prompt mới vào context rồi chạy; agentLoopContinue() chạy tiếp từ context có sẵn mà không thêm gì, dùng cho thử lại. Cả hai trả về ngay một EventStream và để vòng lặp chạy nền:
export function agentLoop( prompts: AgentMessage[], context: AgentContext, config: AgentLoopConfig, signal: AbortSignal | undefined, streamFn: StreamFn,): EventStream<AgentEvent, AgentMessage[]> { const stream = createAgentStream(); void runAgentLoop( prompts, context, config, async (event) => { stream.push(event); }, signal, streamFn, ).then((messages) => { stream.end(messages); }); return stream;}Luồng kết thúc khi gặp agent_end, và stream.result() trả về danh sách message mới (agent-loop.ts:162–167). Chú ý hàm emit ở dòng 60–62 chỉ push vào luồng rồi trả về ngay, nên vòng lặp không chờ người tiêu thụ xử lý xong một sự kiện. README gọi các luồng cấp thấp này là “observational”: giữ đúng thứ tự, nhưng không phải rào chắn (README.md:565). Lớp Agent thì gọi thẳng runAgentLoop() với một emit có await, nên mỗi sự kiện trở thành một rào chắn.
export async function runAgentLoop( prompts: AgentMessage[], context: AgentContext, config: AgentLoopConfig, emit: AgentEventSink, signal: AbortSignal | undefined, streamFn: StreamFn,): Promise<AgentMessage[]> { const initialMessages = declareToolChanges(context, prompts); const newMessages: AgentMessage[] = [...initialMessages]; const currentContext: AgentContext = { ...context, messages: [...context.messages, ...initialMessages], }; await emit({ type: "agent_start" }); await emit({ type: "turn_start" }); for (const message of initialMessages) { await emit({ type: "message_start", message }); await emit({ type: "message_end", message }); } await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn()); return newMessages;}Các message của prompt đi qua declareToolChanges() trước (dòng 119; giải thích ở mục sau), được nối vào một bản sao của transcript, rồi phát thành cặp message_start/message_end ngay sau agent_start và turn_start đầu tiên. runAgentLoopContinue() giống vậy nhưng không có message mới, và từ chối chạy nếu transcript rỗng hoặc kết thúc bằng assistant message (agent-loop.ts:144–150), vì provider sẽ từ chối một request kết thúc bằng câu trả lời của chính mô hình.
Khởi tạo và hai vòng while
async function runLoop( initialContext: AgentContext, newMessages: AgentMessage[], initialConfig: AgentLoopConfig, signal: AbortSignal | undefined, emit: AgentEventSink, streamFunction: StreamFn,): Promise<void> { let currentContext = initialContext; let config = initialConfig; let lastCompletedTurn: PrepareNextTurnContext | undefined; let explicitContinuation = false; // Check for steering messages at start (user may have typed while waiting) let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || []; // Outer loop: continues when queued follow-up messages arrive after agent would stop while (true) { let hasMoreToolCalls = true; // Inner loop: process tool calls and steering messages while (hasMoreToolCalls || pendingMessages.length > 0) {Toàn bộ state của vòng lặp là vài biến cục bộ. lastCompletedTurn nhớ lượt vừa xong để truyền cho prepareNextTurn. explicitContinuation nhớ rằng finishTurn đã đòi thêm một request. pendingMessages là các tin nhắn sẽ được chèn trước request kế tiếp, và nó được khởi tạo bằng một lần hỏi hàng đợi steer ngay từ đầu (dòng 185), vì người dùng có thể đã gõ trong lúc chờ. hasMoreToolCalls bắt đầu là true nên lần lặp đầu tiên luôn gọi mô hình.
Đầu mỗi lượt: chuẩn bị và chèn tin nhắn chờ
let preparedMessages: AgentMessage[] = [];if (lastCompletedTurn) { const nextTurnSnapshot = await config.prepareNextTurn?.(lastCompletedTurn); if (nextTurnSnapshot) { currentContext = nextTurnSnapshot.context ?? currentContext; preparedMessages = nextTurnSnapshot.messages ?? []; config = { ...config, model: nextTurnSnapshot.model ?? config.model, reasoning: nextTurnSnapshot.thinkingLevel === undefined ? config.reasoning : nextTurnSnapshot.thinkingLevel === "off" ? undefined : nextTurnSnapshot.thinkingLevel, }; } // Preparation can be long-running (for example, compaction). Pick up steering // queued while it ran. Only poll again if the earlier poll returned nothing; // otherwise one-at-a-time mode would deliver two messages in this turn. if (pendingMessages.length === 0) { pendingMessages = (await config.getSteeringMessages?.()) || []; } await emit({ type: "turn_start" });}// Process prepared and queued messages before the next assistant response.for (const message of declareToolChanges(currentContext, [...preparedMessages, ...pendingMessages])) { await emit({ type: "message_start", message }); await emit({ type: "message_end", message }); currentContext.messages.push(message); newMessages.push(message);}pendingMessages = [];Từ lượt thứ hai trở đi (khi lastCompletedTurn đã có), vòng lặp gọi prepareNextTurn. Hook này có thể thay context, model, mức suy nghĩ, hoặc trả về message cần nối thêm; coding agent dùng nó để compaction trước câu trả lời kế tiếp và gắn system message cập nhật prompt. Mức suy nghĩ "off" được dịch thành reasoning: undefined (dòng 202–207). Vì chuẩn bị có thể lâu, vòng lặp hỏi lại hàng đợi steer sau đó, nhưng chỉ khi lần hỏi trước rỗng: chú thích ở dòng 210–212 giải thích rằng hỏi hai lần sẽ khiến chế độ one-at-a-time giao hai tin trong một lượt. Rồi mới phát turn_start.
Dòng 220–224 là chỗ duy nhất tin nhắn chờ, dù là steer, follow-up hay message từ prepareNextTurn, đi vào transcript: mỗi message được phát message_start/message_end, rồi nối vào cả context lẫn newMessages.
Gọi mô hình và lối ra cứng
const requestUpdate = await config.prepareRequest?.( { context: currentContext, model: config.model, thinkingLevel: config.reasoning ?? "off", }, signal,);if (requestUpdate) { currentContext = requestUpdate.context ?? currentContext; config = { ...config, model: requestUpdate.model ?? config.model, reasoning: requestUpdate.thinkingLevel === undefined ? config.reasoning : requestUpdate.thinkingLevel === "off" ? undefined : requestUpdate.thinkingLevel, };}// Stream assistant responseconst message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);newMessages.push(message);if (message.stopReason === "error" || message.stopReason === "aborted") { lastCompletedTurn = { message, toolResults: [], context: currentContext, newMessages, }; await config.finishTurn?.(lastCompletedTurn, signal); await emit({ type: "turn_end", message, toolResults: [] }); await emit({ type: "agent_end", messages: newMessages }); return;}prepareRequest chạy ngay trước mỗi request, sau khi tin nhắn chờ đã được phát. Coding agent dùng nó để thay toàn bộ context bằng phép chiếu chuẩn dựng từ SessionManager, vốn là nguồn có thẩm quyền cho context của mô hình (sdk.md:40, agent-session.ts:787–800). Kết quả của streamAssistantResponse() được nối vào newMessages bất kể thành công hay không. Nếu stopReason là "error" hoặc "aborted", vòng lặp vẫn gọi finishTurn (để hook quan sát), phát turn_end và agent_end, rồi return: không chạy tool nào, không hỏi hàng đợi nào, và giá trị trả về của finishTurn bị bỏ qua.
Cuối lượt: tool, finishTurn và hàng đợi steer
// Check for tool calls const toolCalls = message.content.filter((c) => c.type === "toolCall"); const toolResults: ToolResultMessage[] = []; hasMoreToolCalls = false; if (toolCalls.length > 0) { // A "length" stop means the output was cut off by the token limit, so // every tool call in the message may carry truncated arguments. Fail // them all instead of executing potentially borked calls. const executedToolBatch = message.stopReason === "length" ? await failToolCallsFromTruncatedMessage(toolCalls, emit) : await executeToolCalls(currentContext, message, config, signal, emit); toolResults.push(...executedToolBatch.messages); hasMoreToolCalls = !executedToolBatch.terminate; for (const result of toolResults) { currentContext.messages.push(result); newMessages.push(result); } } lastCompletedTurn = { message, toolResults, context: currentContext, newMessages, }; const decision = await config.finishTurn?.(lastCompletedTurn, signal); await emit({ type: "turn_end", message, toolResults }); if (decision?.action === "end") { await emit({ type: "agent_end", messages: newMessages }); return; } explicitContinuation = decision?.action === "continue"; pendingMessages = (await config.getSteeringMessages?.()) || []; if (hasMoreToolCalls || pendingMessages.length > 0) { explicitContinuation = false; }}Batch tool được chạy bởi executeToolCalls(), trừ khi assistant message dừng vì chạm giới hạn token đầu ra (stopReason === "length"); khi đó mọi lời gọi tool trong message bị đánh lỗi mà không chạy (dòng 273–279, xem mục thực thi tool). Sau batch, hasMoreToolCalls là true trừ khi cả batch xin dừng (dòng 281): kết quả tool phải được đưa lại cho mô hình, nên có tool call nghĩa là có thêm một lượt.
Thứ tự ở dòng 295–307 là cố ý: finishTurn chạy trước turn_end nhưng quyết định của nó được áp dụng sau. Với { action: "end" }, vòng lặp phát agent_end ngay, để nguyên hai hàng đợi và không gọi prepareNextTurn (CHANGELOG.md:68). Nếu không, nó hỏi hàng đợi steer (dòng 304). Đây là điểm mà tin nhắn steer đi vào: sau khi mọi tool của lượt đã xong và đã có toolResult, trước lần gọi mô hình kế tiếp. Nếu đã có tool call hoặc tin steer, request tự nhiên kế tiếp đã thoả một { action: "continue" }, nên cờ explicitContinuation được xoá (dòng 305–307).
Khi vòng trong muốn dừng: follow-up
// Agent would stop here. Check for follow-up messages. const followUpMessages = (await config.getFollowUpMessages?.()) || []; if (followUpMessages.length > 0) { // Set as pending so inner loop processes them explicitContinuation = false; pendingMessages = followUpMessages; continue; } // No natural request was selected, so fulfill the continuation decision with one context-only turn. if (explicitContinuation) { explicitContinuation = false; continue; } // No more messages, exit break; } await emit({ type: "agent_end", messages: newMessages });}Vòng trong thoát khi lượt vừa rồi không có tool call (hoặc cả batch xin dừng) và hàng đợi steer rỗng. Lúc đó, và chỉ lúc đó, hàng đợi follow-up được hỏi. Có tin thì chúng trở thành pendingMessages và vòng ngoài quay lại vòng trong; không có thì xét continuation tường minh từ finishTurn, cuối cùng là break và agent_end. Đây chính là khác biệt giữa steer và follow-up: steer được hỏi sau mỗi lượt, follow-up chỉ được hỏi khi agent đã xong việc.
streamAssistantResponse: nơi AgentMessage thành Message#
Chú thích đầu file nói rõ nguyên tắc: vòng lặp làm việc với AgentMessage ở mọi chỗ và chỉ biến đổi sang Message[] tại ranh giới gọi mô hình (agent-loop.ts:1–4). Ranh giới đó là nửa đầu của streamAssistantResponse():
async function streamAssistantResponse( context: AgentContext, config: AgentLoopConfig, signal: AbortSignal | undefined, emit: AgentEventSink, streamFunction: StreamFn,): Promise<AssistantMessage> { // Apply context transform if configured (AgentMessage[] → AgentMessage[]) let messages = context.messages; if (config.transformContext) { messages = await config.transformContext(messages, signal); } // Convert to LLM-compatible messages (AgentMessage[] → Message[]) const llmMessages = await config.convertToLlm(messages); const llmContext = normalizeContext({ messages: llmMessages }); // Resolve API key (important for expiring tokens) const resolvedApiKey = (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey; const response = await streamFunction(config.model, llmContext, { ...config, apiKey: resolvedApiKey, identity: mergeClientIdentity(DEFAULT_AGENT_IDENTITY, config.identity), signal, }); // Record the requested level, whichever stream function answered. const result = async () => Object.assign(await response.result(), { thinkingLevel: config.reasoning ?? "off" });AgentMessage[] → transformContext() → AgentMessage[] → convertToLlm() → Message[] → LLM (README.md:54).- transformContextTuỳ chọn, trên
AgentMessage[]: cắt bớt, chèn ngữ cảnh. Coding agent chuyển nó cho sự kiệncontextcủa extension. - convertToLlmBắt buộc: lọc message chỉ dành cho UI, đổi message tuỳ biến thành
user. - normalizeContextGắn nhãn kiểu
TranscriptContext; prompt và tool đã nằm trong system message. - getApiKeyHỏi key cho từng request, rơi về
config.apiKey. - streamFunctionGọi với
{ ...config, apiKey, identity, signal }.
Vì AgentLoopConfig kế thừa SimpleStreamOptions, cả đối tượng cấu hình được rải vào options (dòng 413), nên reasoning, sessionId, transport, thinkingBudgets, onPayload... tự đi tới pi-ai. identity là danh tính client (User-Agent, header attribution), mặc định ghi công cho pi và được gộp từng trường với giá trị của caller (agent-loop.ts:37–41), tính năng mới của 1.4.0. Dòng 419 bọc result() để ghi mức suy nghĩ đã yêu cầu lên assistant message cuối, dù hàm stream nào trả lời.
Nửa sau đọc luồng sự kiện của pi-ai và giữ cho transcript luôn có một assistant message đang lớn dần:
for await (const event of response) { switch (event.type) { case "start": partialMessage = event.partial; context.messages.push(partialMessage); addedPartial = true; await emit({ type: "message_start", message: { ...partialMessage } }); break; case "text_start": case "text_delta": case "text_end": case "thinking_start": case "thinking_delta": case "thinking_end": case "toolcall_start": case "toolcall_delta": case "toolcall_end": if (partialMessage) { partialMessage = event.partial; context.messages[context.messages.length - 1] = partialMessage; await emit({ type: "message_update", assistantMessageEvent: event, message: { ...partialMessage }, }); } break; case "done": case "error": { const finalMessage = await result(); if (addedPartial) { context.messages[context.messages.length - 1] = finalMessage; } else { context.messages.push(finalMessage); } if (!addedPartial) { await emit({ type: "message_start", message: { ...finalMessage } }); } await emit({ type: "message_end", message: finalMessage }); return finalMessage; }Ở sự kiện start, message dở dang được push thẳng vào context.messages; mỗi sự kiện delta thay phần tử cuối bằng bản partial mới; done hoặc error thay nó bằng message cuối cùng. Nhờ vậy, khi batch tool bắt đầu, context đã chứa đúng assistant message vừa yêu cầu các tool đó, và các hook beforeToolCall thấy nó. Lưu ý message_update chỉ được phát khi đã có start (dòng 442); một luồng chỉ có một sự kiện error (lỗi trước khi sinh) vẫn có đủ cặp message_start/message_end nhờ dòng 461–463.
Thực thi tool: tuần tự, song song, hook và terminate#
Mỗi lời gọi tool đi qua cùng một đường ống bốn bước, bất kể chế độ: chuẩn bị (tìm tool, chuẩn hoá và kiểm tra đối số, beforeToolCall), chạy (execute()), hoàn tất (afterToolCall), rồi phát (tool_execution_end và message toolResult). Chế độ thực thi chỉ quyết định các bước đó của nhiều lời gọi xen kẽ nhau thế nào.
async function executeToolCalls( currentContext: AgentContext, assistantMessage: AssistantMessage, config: AgentLoopConfig, signal: AbortSignal | undefined, emit: AgentEventSink,): Promise<ExecutedToolCallBatch> { const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall"); const hasSequentialToolCall = toolCalls.some( (tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential", ); if (config.toolExecution === "sequential" || hasSequentialToolCall) { return executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit); } return executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit);}Mặc định là song song (Agent.toolExecution mặc định "parallel", agent.ts:261). Chỉ cần một lời gọi trong batch nhắm vào tool có executionMode: "sequential" là cả batch chạy tuần tự.
Chuẩn bị: từ khối toolCall tới đối số hợp lệ
const tool = tools.find((t) => t.name === toolCall.name);if (!tool) { return { kind: "immediate", result: createErrorToolResult(`Tool ${toolCall.name} not found`), isError: true, };}try { const preparedToolCall = prepareToolCallArguments(tool, toolCall); const validatedArgs = validateToolArguments(tool, preparedToolCall); if (config.beforeToolCall) { const beforeResult = await config.beforeToolCall( { assistantMessage, toolCall, args: validatedArgs, context: currentContext, }, signal, ); if (signal?.aborted) { return { kind: "immediate", result: createErrorToolResult("Operation aborted"), isError: true, }; } if (beforeResult?.block) { const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked"); if (beforeResult.terminate === true) { result.terminate = true; } return { kind: "immediate", result, isError: true, }; }Mọi thất bại ở bước này đều thành một kết quả "immediate" có isError: true, không bao giờ là exception: tool không tồn tại (“Tool X not found”), đối số không qua schema, hook chặn, hoặc run đã bị huỷ. Phần cuối hàm (agent-loop.ts:767–786) kiểm tra abort lần nữa, và bọc mọi exception thành kết quả lỗi mang thông báo của exception, nên mô hình đọc được lý do và có thể sửa lời gọi.
Thứ tự bên trong có ý nghĩa. prepareArguments của tool chạy trước (agent-loop.ts:704–716), rồi validateToolArguments() của pi-ai sao chép đối số, ép kiểu theo schema TypeBox và kiểm tra; nếu JSON của mô hình hỏng, pi-ai đã ghi argumentsError lên khối toolCall và hàm này throw ngay (validation.ts:317–323). beforeToolCall nhận đối số đã kiểm tra. Nó có thể sửa đối tượng đó tại chỗ, và tool chạy với bản đã sửa mà không kiểm tra lại; có hẳn một test cho hành vi này (agent-loop.test.ts:528). Riêng sự kiện tool_execution_start luôn mang toolCall.arguments thô, chưa qua chuẩn hoá.
Chạy và hoàn tất
async function executePreparedToolCall( prepared: PreparedToolCall, signal: AbortSignal | undefined, onUpdate: ToolUpdateSink,): Promise<ExecutedToolCallOutcome> { const updateEvents: Promise<void>[] = []; let acceptingUpdates = true; const startedAt = performance.now(); const elapsed = () => Math.round(performance.now() - startedAt); try { const result = await prepared.tool.execute( prepared.toolCall.id, prepared.args as never, signal, (partialResult) => { if (!acceptingUpdates) return; updateEvents.push(Promise.resolve(onUpdate(partialResult))); }, ); const durationMs = elapsed(); acceptingUpdates = false; await Promise.all(updateEvents); return { result, isError: result.isError === true, durationMs }; } catch (error) { const durationMs = elapsed(); acceptingUpdates = false; await Promise.all(updateEvents); return { result: createErrorToolResult(error instanceof Error ? error.message : String(error)), isError: true, durationMs, }; } finally { acceptingUpdates = false; }}Cờ acceptingUpdates là cơ chế khiến các lời gọi onUpdate muộn bị bỏ qua. Thời gian chỉ đo riêng execute(). Exception thành kết quả lỗi với thông báo của nó, đúng như README dặn: tool hỏng thì cứ throw, đừng trả thông báo lỗi trong content (README.md:495).
if (config.afterToolCall) { try { const afterResult = await config.afterToolCall( { assistantMessage, toolCall: prepared.toolCall, args: prepared.args, result, isError, context: currentContext, }, signal, ); if (afterResult) { // Structured content not replaced along with the content may no longer match it. const structuredContent = afterResult.structuredContent ?? (afterResult.content ? undefined : result.structuredContent); result = { ...result, content: afterResult.content ?? result.content, details: afterResult.details ?? result.details, usage: afterResult.usage ?? result.usage, terminate: afterResult.terminate ?? result.terminate, }; if (structuredContent === undefined) delete result.structuredContent; else result.structuredContent = structuredContent; isError = afterResult.isError ?? isError; } } catch (error) { result = createErrorToolResult(error instanceof Error ? error.message : String(error)); isError = true; }afterToolCall ghi đè theo từng trường, không gộp sâu: trả về content thì thay cả mảng content. Có một ngoại lệ cố ý ở dòng 894–896: nếu hook thay content mà không trả structuredContent, thì structuredContent cũ bị bỏ, vì có thể nó không còn khớp với content mới. Hook throw thì kết quả trở thành lỗi thay vì làm hỏng cả batch, một sửa lỗi ở 0.67.67 (CHANGELOG.md:419).
Tuần tự và song song khác nhau ở thứ tự sự kiện
for (const toolCall of toolCalls) { await emit({ type: "tool_execution_start", toolCallId: toolCall.id, toolName: toolCall.name, args: toolCall.arguments, }); const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal); let finalized: FinalizedToolCallOutcome; if (preparation.kind === "immediate") { finalized = { toolCall, result: preparation.result, isError: preparation.isError, }; } else { const executed = await executePreparedToolCall(preparation, signal, emitToolExecutionUpdate(toolCall, emit)); finalized = await finalizeExecutedToolCall( currentContext, assistantMessage, preparation, executed, config, signal, ); } await emitToolExecutionEnd(finalized, emit); const toolResultMessage = createToolResultMessage(finalized); await emitToolResultMessage(toolResultMessage, emit); finalizedCalls.push(finalized); messages.push(toolResultMessage); if (signal?.aborted) { break; }}Ở chế độ tuần tự, mỗi lời gọi phát tool_execution_start, chạy, phát tool_execution_end và message toolResult, rồi mới tới lời gọi sau. Nếu run bị huỷ, vòng for dừng sau lời gọi đang chạy (dòng 585–587); các lời gọi chưa tới không có kết quả nào trong transcript.
for (const toolCall of toolCalls) { await emit({ type: "tool_execution_start", toolCallId: toolCall.id, toolName: toolCall.name, args: toolCall.arguments, }); const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal); if (preparation.kind === "immediate") { const finalized = { toolCall, result: preparation.result, isError: preparation.isError, } satisfies FinalizedToolCallOutcome; await emitToolExecutionEnd(finalized, emit); finalizedCalls.push(finalized); if (signal?.aborted) { break; } continue; } finalizedCalls.push(async () => { if (signal?.aborted) { const finalized = { toolCall, result: createErrorToolResult("Operation aborted"), isError: true, } satisfies FinalizedToolCallOutcome; await emitToolExecutionEnd(finalized, emit); return finalized; } const executed = await executePreparedToolCall(preparation, signal, emitToolExecutionUpdate(toolCall, emit)); const finalized = await finalizeExecutedToolCall( currentContext, assistantMessage, preparation, executed, config, signal, ); await emitToolExecutionEnd(finalized, emit); return finalized; }); if (signal?.aborted) { break; }}const orderedFinalizedCalls = await Promise.all( finalizedCalls.map((entry) => (typeof entry === "function" ? entry() : Promise.resolve(entry))),);const messages: ToolResultMessage[] = [];for (const finalized of orderedFinalizedCalls) { const toolResultMessage = createToolResultMessage(finalized); await emitToolResultMessage(toolResultMessage, emit); messages.push(toolResultMessage);}return { messages, terminate: shouldTerminateToolBatch(orderedFinalizedCalls),};Ở chế độ song song có hai pha. Pha 1 chuẩn bị tuần tự: mọi tool_execution_start và mọi beforeToolCall chạy lần lượt theo thứ tự trong assistant message. Lời gọi bị chặn hay lỗi đối số được phát tool_execution_end ngay. Lời gọi hợp lệ được gói thành một closure chưa chạy. Pha 2 chạy mọi closure cùng lúc bằng Promise.all; mỗi closure phát tool_execution_end ngay khi nó xong, tức theo thứ tự hoàn thành. Chỉ khi tất cả đã xong, các message toolResult mới được phát, theo thứ tự nguồn (dòng 660–664). Test ở agent-loop.test.ts:668 khẳng định đúng điều đó: tool_execution_end ra ["tool-2", "tool-1"] còn toolResult ra ["tool-1", "tool-2"].
Tuần tự
start #1 end #1 toolResult #1 start #2 end #2 toolResult #2
Song song (#2 xong trước)
start #1 start #2 (pha 1) end #2 end #1 (thứ tự xong) toolResult #1 toolResult #2 (thứ tự nguồn)
Vì sao chuẩn bị lại tuần tự? beforeToolCall là chỗ coding agent chạy hook tool_call của extension, nơi kiểm tra quyền và hỏi người dùng. Khi chuyển sang chạy song song ở 0.58.0, CHANGELOG của coding agent ghi rõ preflight tuần tự được giữ lại cho việc chặn của extension (coding-agent/CHANGELOG.md:3296). Hệ quả thấy được: các hộp thoại hỏi quyền hiện lần lượt, theo thứ tự lời gọi trong message. Còn ghi transcript theo thứ tự nguồn thì làm cho transcript được lưu ra là tất định, không phụ thuộc tool nào chạy nhanh hơn.
terminate: dừng sau batch
Một tool có thể trả terminate: true để gợi ý rằng không cần gọi mô hình thêm lần nào sau batch này, ví dụ một tool “báo xong việc”. Hook beforeToolCall (khi chặn) và afterToolCall cũng đặt được cờ này. Quy tắc mang tính cả batch: chỉ khi mọi kết quả đã hoàn tất đều đặt terminate, vòng lặp mới không tự gọi mô hình tiếp (shouldTerminateToolBatch, agent-loop.ts:700–702). Một batch lẫn lộn chạy bình thường. Và terminate chỉ bỏ qua lần gọi tự động; tin steer hay follow-up đang chờ vẫn được xử lý.
Lời gọi tool bị cắt cụt vì giới hạn token
/** * Fail all tool calls from an assistant message that was truncated by the * output token limit. Streamed tool-call arguments are finalized with a * best-effort JSON salvage parser, so a truncated message can yield tool calls * whose arguments parse and validate but are silently incomplete. None of them * are safe to execute; report each as an error so the model can re-issue them. */async function failToolCallsFromTruncatedMessage( toolCalls: AgentToolCall[], emit: AgentEventSink,): Promise<ExecutedToolCallBatch> { const messages: ToolResultMessage[] = []; for (const toolCall of toolCalls) { await emit({ type: "tool_execution_start", toolCallId: toolCall.id, toolName: toolCall.name, args: toolCall.arguments, }); const finalized: FinalizedToolCallOutcome = { toolCall, result: createErrorToolResult( `Tool call "${toolCall.name}" was not executed: the response hit the output token limit, so its arguments may be truncated. Re-issue the tool call with complete arguments.`, ), isError: true, }; await emitToolExecutionEnd(finalized, emit); const toolResultMessage = createToolResultMessage(finalized); await emitToolResultMessage(toolResultMessage, emit); messages.push(toolResultMessage); } return { messages, terminate: false };}Khi mô hình chạm giới hạn token đầu ra giữa chừng, đối số tool đang stream bị cắt. Bộ đọc JSON cứu vãn của pi-ai vẫn có thể dựng ra một đối tượng hợp lệ, chỉ là thiếu nội dung, và nó còn qua được schema. Chạy lời gọi đó (ví dụ một lệnh write với nội dung bị cụt) là nguy hiểm. Vì vậy mọi lời gọi trong message có stopReason === "length" đều bị trả lỗi, kèm lời nhắn bảo mô hình gửi lại lời gọi với đối số đầy đủ, và terminate: false bảo đảm mô hình được gọi lại để làm việc đó. Test ở agent-loop.test.ts:456 kiểm tra rằng tool không bao giờ chạy và vòng lặp gọi mô hình lần thứ hai.
runToolCall: cùng đường ống cho lời gọi lồng nhau
/** * Run one tool call through the same steps as a model-issued call: argument preparation, schema * validation, `beforeToolCall`, execution, and `afterToolCall`. Emits no events and adds no * messages. Tools that call other tools use this so the hooks (for example permission checks) * apply to those calls too. * * Never rejects for tool failures: unknown tools, validation errors, blocked calls, and thrown * errors come back as `isError: true`. */export async function runToolCall(toolCall: AgentToolCall, options: RunToolCallOptions): Promise<AgentToolCallOutcome> { const { assistantMessage, context, signal } = options; const preparation = await prepareToolCall(context, assistantMessage, toolCall, options, signal, options.tools); if (preparation.kind === "immediate") { return { toolCall, result: preparation.result, isError: preparation.isError }; } const executed = await executePreparedToolCall(preparation, signal, options.onUpdate ?? (() => {})); return finalizeExecutedToolCall(context, assistantMessage, preparation, executed, options, signal);}Một tool đôi khi gọi tool khác, ví dụ script codemode gọi tools.read(...). Nếu nó gọi thẳng execute(), các hook kiểm tra quyền bị bỏ qua. runToolCall() chạy đúng các bước chuẩn bị, chạy và hoàn tất như lời gọi của mô hình, nhưng không phát sự kiện và không thêm message. Ví dụ examples/mcp-codemode dùng nó cho tool codemode (tools.ts:83–103); chi tiết ở chương Codemode.
declareToolChanges: báo thay đổi bộ tool giữa hội thoại#
Vấn đề. Bộ tool chạy được có thể đổi giữa hội thoại: extension bật một tool, người dùng tắt bash, một server MCP nạp thêm tool. Nếu mỗi request gửi danh sách tool hiện tại ở một trường riêng, thì mọi thay đổi đều làm đổi tiền tố của request và prompt cache bị trượt. Bài “Prompt Caching In Agents” của Earendil nói đúng điều này: thay đổi bộ tool phá cache, và cách sửa là nạp tool kiểu cộng dồn, neo vào transcript (Prompt Caching In Agents). Bài đó (07/2026) mô tả cơ chế lúc ấy là ghi tên tool mới lên kết quả tool (addedToolNames, 0.80.7); từ 0.86.0, prompt và khai báo tool chuyển hẳn vào các system message của transcript (ai/CHANGELOG.md:284), và đó là cơ chế ở v1.4.0.
Giải pháp. SystemMessage của pi-ai có toolsAdded (định nghĩa đầy đủ) và toolsRemoved (chỉ tên) (ai/types.ts:544–570). Phát lại mọi system message theo thứ tự cho ra bộ tool hiện hành (getCurrentTools). Còn context.tools là thứ runtime chạy được. Trước mỗi request, vòng lặp so hai thứ đó và, nếu khác, chèn một system message ghi phần chênh lệch:
/** * Declare tool loadout changes to the model. * * `context.tools` is what the runtime can execute; the transcript's system messages declare * what the model may call. Before each request the difference becomes `toolsAdded` and * `toolsRemoved` on a system message. When a pending system message exists, its tool fields * are treated as intent and replaced with the delta between the committed transcript and * the executable set, so replay always yields exactly `context.tools`. Otherwise a new * system message is inserted before the first non-system pending message. */function declareToolChanges(context: AgentContext, pendingMessages: AgentMessage[]): AgentMessage[] { let systemIndex = -1; for (let i = pendingMessages.length - 1; i >= 0; i--) { if (pendingMessages[i].role === "system") { systemIndex = i; break; } } const pending = pendingMessages[systemIndex] as SystemMessage | undefined; const baseline = pending ? pendingMessages.map((message, index) => index === systemIndex ? withToolChanges(pending, NO_CHANGES) : message, ) : pendingMessages; const changes = getToolStateChanges( getCurrentTools([...context.messages, ...baseline]), (context.tools ?? []).map(toToolDeclaration), ); const unchanged = changes.toolsAdded.length === 0 && changes.toolsRemoved.length === 0; if (pending) { // Keep the caller's message object when it already declares no tool changes. if (unchanged && !pending.toolsAdded?.length && !pending.toolsRemoved?.length) return pendingMessages; return baseline.map((message, index) => (index === systemIndex ? withToolChanges(pending, changes) : message)); } if (unchanged) return pendingMessages; const update = withToolChanges({ role: "system", content: "", timestamp: Date.now() }, changes); const insertIndex = pendingMessages.findIndex((message) => message.role !== "system"); const index = insertIndex === -1 ? pendingMessages.length : insertIndex; return [...pendingMessages.slice(0, index), update, ...pendingMessages.slice(index)];}Hai trường hợp. Nếu trong các tin nhắn chờ không có system message nào, một system message mới với content: "" và phần chênh lệch được chèn trước tin nhắn chờ đầu tiên không phải system (dòng 367–371). Nếu caller đã tự gửi kèm một system message (chẳng hạn để đổi một mục prompt), phần tool của message đó bị viết lại cho khớp bộ chạy được: bộ chạy được luôn thắng, dù message có “xin” thêm hay bớt tool nào (dòng 351–355, 362–366; xem test ở agent.test.ts:247). Một tool đổi định nghĩa được tính là bỏ rồi thêm (transcript.ts:149).
Hàm được gọi ở hai chỗ: cho các message của prompt (agent-loop.ts:119) và trước mỗi request trong vòng trong (:220), kể cả khi không có tin nhắn chờ nào. Test ở agent.test.ts:164–198 cho thấy hiệu quả: sau khi đổi agent.state.tools từ [first] sang [second], request kế tiếp mang thêm một system message +second, -first, còn các message trước đó, kể cả system message đầu tiên, không bị sửa. Provider nào nhận được system message giữa hội thoại thì gửi tại chỗ; provider khác được pi-ai gộp lại thành một system message đầu (chương pi-ai).
Lớp Agent: state, hàng đợi và vòng đời một lần chạy#
Agent thêm vào vòng lặp ba thứ mà một ứng dụng tương tác cần: state sống lâu hơn một lần chạy, hai hàng đợi tin nhắn, và một vòng đời rõ ràng (đang chạy, huỷ, chờ rảnh).
State chỉ đổi qua sự kiện
private createContextSnapshot(): AgentContext { return { messages: this._state.messages.slice(), tools: this._state.tools.slice(), };}private createLoopConfig(options: { skipInitialSteeringPoll?: boolean } = {}): AgentLoopConfig { let skipInitialSteeringPoll = options.skipInitialSteeringPoll === true; return { model: this._state.model, reasoning: this._state.thinkingLevel === "off" ? undefined : this._state.thinkingLevel, convertToLlm: this.convertToLlm, transformContext: this.transformContext, getApiKey: this.getApiKey, getSteeringMessages: async () => { if (skipInitialSteeringPoll) { skipInitialSteeringPoll = false; return []; } return this.steeringQueue.drain(); }, getFollowUpMessages: async () => this.followUpQueue.drain(), };}Mỗi lần chạy, Agent đưa cho vòng lặp một bản sao của transcript và bộ tool (dòng 469–474). Vòng lặp chỉ sửa bản sao đó. State của Agent thay đổi ở một chỗ duy nhất, hàm processEvents(), như một reducer chạy trên từng sự kiện, rồi mới await từng listener theo thứ tự đăng ký:
private async processEvents(event: AgentEvent): Promise<void> { switch (event.type) { case "message_start": this._state.streamingMessage = event.message; break; case "message_update": this._state.streamingMessage = event.message; break; case "message_end": this._state.streamingMessage = undefined; this._state.messages.push(event.message); break; case "turn_end": if (event.message.role === "assistant" && event.message.errorMessage) { this._state.errorMessage = event.message.errorMessage; } break; case "agent_end": this._state.streamingMessage = undefined; break; } const signal = this.activeRun?.abortController.signal; if (!signal) { throw new Error("Agent listener invoked outside active run"); } for (const listener of this.listeners) { await listener(event, signal); }| Sự kiện | Thay đổi trong agent.state |
|---|---|
message_start, message_update | streamingMessage = message đang stream |
message_end | xoá streamingMessage, messages.push(message) |
tool_execution_start / _end | thêm / bớt id trong pendingToolCalls (mỗi lần một Set mới, agent.ts:590–602) |
turn_end | errorMessage nếu assistant message có lỗi |
agent_end | xoá streamingMessage |
Vì emit của Agent await toàn bộ chuỗi trên, mỗi sự kiện là một rào chắn: vòng lặp không đi tiếp cho tới khi state đã cập nhật và mọi listener đã xong. README nêu hệ quả quan trọng nhất: khi beforeToolCall chạy, state đã chứa assistant message yêu cầu tool đó (README.md:122). Listener còn nhận AbortSignal của lần chạy để huỷ việc của chính nó.
prompt() và continue()
async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]): Promise<void> { if (this.activeRun) { throw new Error( "Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.", ); } const messages = this.normalizePromptInput(input, images); await this.runPromptMessages(messages);}/** Continue from the current transcript. The last message must be a user or tool-result message. */async continue(): Promise<void> { if (this.activeRun) { throw new Error("Agent is already processing. Wait for completion before continuing."); } const lastMessage = this._state.messages[this._state.messages.length - 1]; if (!lastMessage || this._state.messages.every((message) => message.role === "system")) { throw new Error("No messages to continue from"); } if (lastMessage.role === "assistant") { const queuedSteering = this.steeringQueue.drain(); if (queuedSteering.length > 0) { await this.runPromptMessages(queuedSteering, { skipInitialSteeringPoll: true }); return; } const queuedFollowUps = this.followUpQueue.drain(); if (queuedFollowUps.length > 0) { await this.runPromptMessages(queuedFollowUps); return; } throw new Error("Cannot continue from message role: assistant"); } await this.runContinuation();}prompt() nhận một chuỗi (kèm ảnh tuỳ chọn), một message hoặc một mảng message; chuỗi được bọc thành một message user (agent.ts:422–439). Gọi prompt() khi đang chạy là lỗi, với lời nhắn chỉ rõ cách đúng: dùng steer() hoặc followUp(). Quy tắc này có từ 0.32.0 để tránh hai lần chạy giẫm lên cùng một transcript (CHANGELOG.md:749).
continue() có ba nhánh. Transcript rỗng hoặc chỉ có system message thì từ chối. Đuôi không phải assistant (thường là user hoặc toolResult) thì chạy runAgentLoopContinue(), ví dụ để thử lại sau lỗi. Đuôi là assistant thì không thể gửi thẳng, nên nó lấy một lô từ hàng đợi steer, nếu rỗng thì một lô từ hàng đợi follow-up, và chạy lô đó như một prompt. Khi lô đến từ hàng đợi steer, lần hỏi steer đầu tiên của vòng lặp bị bỏ qua một lần (cờ skipInitialSteeringPoll), nếu không chế độ one-at-a-time sẽ giao hai tin steer trong cùng một lượt.
Hai hàng đợi
class PendingMessageQueue { private messages: AgentMessage[] = []; public mode: QueueMode; constructor(mode: QueueMode) { this.mode = mode; } enqueue(message: AgentMessage): void { this.messages.push(message); } hasItems(): boolean { return this.messages.length > 0; } peek(): AgentMessage[] { if (this.mode === "all") return this.messages.slice(); const first = this.messages[0]; return first ? [first] : []; } drain(): AgentMessage[] { const drained = this.peek(); this.messages = this.messages.slice(drained.length); return drained; } clear(): void { this.messages = []; }}Cả hai hàng đợi mặc định "one-at-a-time" (agent.ts:255–256): mỗi điểm rút chỉ lấy tin cũ nhất, tin còn lại chờ điểm rút sau. Với "all", mọi tin chờ được chèn cùng lúc. steer() và followUp() chỉ là enqueue đồng bộ (agent.ts:307–315); vòng lặp tự hỏi hàng đợi ở các điểm đã thấy trong runLoop, thông qua hai closure getSteeringMessages/getFollowUpMessages mà createLoopConfig() nối vào drain(). peekQueuedMessages() cho biết lô nào sẽ được chọn kế tiếp mà không lấy ra, với steer được ưu tiên hơn follow-up (agent.ts:338–342).
Vòng đời: chạy, huỷ, chờ rảnh, reset
private async runWithLifecycle(executor: (signal: AbortSignal) => Promise<void>): Promise<void> { if (this.activeRun) { throw new Error("Agent is already processing."); } const abortController = new AbortController(); let resolvePromise = () => {}; const promise = new Promise<void>((resolve) => { resolvePromise = resolve; }); this.activeRun = { promise, resolve: resolvePromise, abortController }; this._state.isStreaming = true; this._state.streamingMessage = undefined; this._state.errorMessage = undefined; try { await executor(abortController.signal); } catch (error) { await this.handleRunFailure(error, abortController.signal.aborted); } finally { this.finishRun(); }}private finishRun(): void { this._state.isStreaming = false; this._state.streamingMessage = undefined; this._state.pendingToolCalls = new Set<string>(); this.activeRun?.resolve(); this.activeRun = undefined;}Mỗi lần chạy có một AbortController riêng và một promise được resolve trong finishRun(). abort() chỉ gọi abortController.abort() (agent.ts:349–352); tín hiệu đó tới hàm stream, tới execute() của tool và tới các hook, và mỗi bên tự tôn trọng nó. waitForIdle() trả về chính promise kia (agent.ts:359–361), nên nó resolve sau khi listener của agent_end đã xong, cùng lúc với prompt().
Nếu vòng lặp throw (ví dụ convertToLlm vi phạm hợp đồng), handleRunFailure() dựng một assistant message rỗng với stopReason "aborted" hoặc "error" và phát đủ message_start, message_end, turn_end, agent_end (agent.ts:542–558), để giao diện không bao giờ bị treo ở trạng thái “đang chạy”. reset() từ chối khi đang chạy, xoá hai hàng đợi và thu transcript về đúng một system message phát lại từ mọi system message cũ, nên prompt và bộ tool hiện hành được giữ (agent.ts:363–377).
Trace: một prompt, hai lời gọi tool và một tin nhắn steer#
Ghép mọi mảnh lại bằng một kịch bản cụ thể. Một Agent dùng cấu hình mặc định (tool chạy song song, hàng đợi one-at-a-time, không hook nào), có một tool read. Người dùng gửi một prompt; mô hình gọi read hai lần trong cùng một câu trả lời; trong lúc hai tool đang chạy, người dùng gõ thêm một tin steer.
Ví dụ minh hoạ: cách gọi
const run = agent.prompt("Đọc a.txt và b.txt rồi tóm tắt");
// ... trong lúc hai tool read đang chạy, từ một handler của giao diện:
agent.steer({ role: "user", content: "Chỉ tóm tắt a.txt thôi", timestamp: Date.now() });
await run;
Đây là chuỗi sự kiện chính xác mà subscriber nhận được, cùng với dòng code phát ra mỗi sự kiện. Giả sử b.txt đọc xong trước a.txt.
Ví dụ minh hoạ: chuỗi AgentEvent của lần chạy
# sự kiện nguồn (agent-loop.ts) agent.state sau reducer
1 agent_start runAgentLoop :126 (isStreaming đã true từ trước)
2 turn_start :127 lượt 1
3 message_start user "Đọc a.txt và b.txt…" :129
4 message_end user :130 messages += user
runLoop: hỏi steer lần đầu → rỗng; declareToolChanges → không đổi; gọi mô hình
5 message_start assistant (partial rỗng) :430 streamingMessage = partial
6 message_update toolcall_start/delta/end :445 (#0 call_1 read {path:"a.txt"})
7 message_update toolcall_start/delta/end :445 (#1 call_2 read {path:"b.txt"})
8 message_end assistant, stopReason "toolUse" :464 messages += assistant
executeToolCallsParallel, pha 1: chuẩn bị tuần tự
9 tool_execution_start call_1 :607 pendingToolCalls {call_1}
10 tool_execution_start call_2 :607 {call_1, call_2}
pha 2: hai execute() chạy cùng lúc; người dùng gọi agent.steer(...) → vào hàng đợi
11 tool_execution_end call_2 :648 thứ tự hoàn thành {call_1}
12 tool_execution_end call_1 :648 {}
13 message_start toolResult call_1 :957 thứ tự nguồn
14 message_end toolResult call_1 :958 messages += toolResult 1
15 message_start toolResult call_2 :957
16 message_end toolResult call_2 :958 messages += toolResult 2
17 turn_end {assistant, toolResults: [call_1, call_2]} :296
hỏi hàng đợi steer → [tin steer]; hasMoreToolCalls = true
18 turn_start :216 lượt 2
19 message_start user "Chỉ tóm tắt a.txt thôi" :221
20 message_end user :222 messages += tin steer
21 message_start assistant (partial) :430
22 message_update text_start, text_delta…, text_end :445
23 message_end assistant, stopReason "stop" :464 messages += assistant
24 turn_end {assistant, toolResults: []} :296
hỏi steer → rỗng, vòng trong thoát; hỏi follow-up → rỗng; không có continue → dừng
25 agent_end messages: [user, asst₁, toolResult₁, toolResult₂, steer, asst₂] :329
listener của agent_end chạy xong → finishRun(): isStreaming = false; prompt() resolve
agent-loop.ts phát sự kiện; số lần message_update thực tế tuỳ provider.Những điều trace này cho thấy:
- Tin steer không cắt ngang tool. Nó nằm trong hàng đợi suốt lúc hai tool chạy và chỉ được rút ở dòng 304, sau
turn_endcủa lượt 1. Không tool nào bị bỏ qua. - Tin steer không tốn thêm request. Lượt 2 vốn đã xảy ra vì có tool call (
hasMoreToolCalls); tin steer chỉ được chèn vào trước request đó. Mô hình thấy theo thứ tự: hai toolResult, rồi chỉ dẫn mới. agent.state.messageslớn dần đúng theo cácmessage_end; lúc listener xử lý sự kiện 9, state đã có assistant message của sự kiện 8.- Nếu thay
steer()bằngfollowUp(), tin nhắn sẽ chờ tới sau sự kiện 24, khi agent lẽ ra đã dừng, và tạo ra lượt thứ ba. Nếu câu trả lời đầu không gọi tool nào, tin steer vẫn tạo ra lượt 2, vì vòng trong chạy tiếp khi còn tin nhắn chờ. - Ở chế độ tuần tự, sự kiện 9–16 sẽ thành
start 1, end 1, toolResult 1, start 2, end 2, toolResult 2.
Quyết định thiết kế#
Lặp cho tới khi mô hình thôi gọi tool, không có max steps
Code ở v1.4.0 giữ đúng lựa chọn đó: AgentLoopConfig không có trường đếm bước nào, và điều kiện của vòng trong chỉ là hasMoreToolCalls || pendingMessages.length > 0. Đánh đổi là một mô hình mắc kẹt có thể lặp mãi. Thay vì một con số tuỳ tiện, các điểm dừng là có chủ đích: người dùng huỷ, finishTurn trả end, cả batch xin terminate, hoặc beforeToolCall chặn tool. Một ứng dụng muốn giới hạn số lượt hoàn toàn viết được giới hạn đó trong finishTurn.
Hàng đợi tin nhắn giữa các lượt, không cắt ngang giữa lượt
Bài blog mô tả ý tưởng gốc: “after each turn, it asks for queued messages and injects them before the next assistant response”, với hai chế độ “one-at-a-time or all-at-once”. Thiết kế này đã đổi hai lần. Ở 0.32.0, một hàng đợi duy nhất tách thành steer() và followUp(), và khi đó steer còn bỏ qua các tool còn lại của batch (CHANGELOG.md:737–739). Ở 0.58.4, steer chuyển sang chờ cả batch tool chạy xong (CHANGELOG.md:507). Hệ quả có thể thấy trực tiếp trong code: mọi lời gọi tool đều có một kết quả thật, và transcript không bao giờ chứa nửa batch. Cái giá là độ trễ: tin steer phải chờ tool chậm nhất của batch. Muốn dừng ngay thì đã có abort().
AgentMessage đi tới tận ranh giới gọi mô hình
Ở 0.31.0, messageTransformer đổi tên thành convertToLlm, và việc xử lý tệp đính kèm chuyển hẳn sang hàm này (CHANGELOG.md:761, :768). Lý do là transcript phải chứa được mọi thứ ứng dụng cần nhớ, còn mỗi request chỉ cần phần mô hình hiểu. Giữ hai kho riêng sẽ buộc ứng dụng tự đồng bộ thứ tự giữa chúng; một kho với một phép chiếu ở ranh giới thì không. Lệnh !!cmd của coding agent là ví dụ: nó nằm trong transcript và hiện trên giao diện, nhưng convertToLlm bỏ nó khỏi request.
State dựng từ sự kiện, listener là rào chắn
Ở 0.65.0, listener của subscribe() bắt đầu được await và nhận AbortSignal; agent_end thôi là ranh giới rảnh (CHANGELOG.md:471–475). Đánh đổi rõ ràng: một listener chậm làm chậm cả vòng lặp. Đổi lại, mọi việc listener làm cho một sự kiện, như ghi session hay chạy extension, đã xong trước khi vòng lặp sang bước kế tiếp, và người gọi await prompt() biết chắc mọi việc đó đã xong khi promise resolve.
Song song là mặc định, nhưng chuẩn bị và transcript thì tuần tự
Chế độ song song có từ 0.58.0 và là mặc định ngay từ đầu (CHANGELOG.md:523); ở 0.68.1, tool_execution_end chuyển sang phát theo thứ tự hoàn thành để giao diện cập nhật ngay khi từng tool xong (:405). Thiết kế ba tầng (chuẩn bị tuần tự, chạy song song, ghi transcript theo thứ tự nguồn) giữ được tốc độ mà không đánh đổi tính tất định của những gì được lưu và gửi lại cho mô hình. Tool nào không an toàn khi chạy đồng thời thì tự khai báo executionMode: "sequential".
Hook là trường public, có thể bọc chồng
Từ 0.65.0, Agent bỏ các setter và để mọi hook là trường public (CHANGELOG.md:454–466). Nhờ vậy nhiều lớp có thể bọc chồng lên nhau mà không cần kế thừa: AgentSession giữ hook cũ trong previousPrepareRequest và previousFinishTurn, rồi gọi nó bên trong hook mới (agent-session.ts:887–895). agent-core vì thế giữ được kích thước nhỏ: nó chỉ định nghĩa khi nào một hook chạy, còn làm gì trong hook thuộc về ứng dụng. Bài “What is a Harness?” của Earendil đặt vòng lặp agent cạnh system prompt, bộ tool và lớp dịch như các phần của harness (What is a Harness?); trong Pi, chỉ phần vòng lặp nằm ở gói này.
Cạm bẫy và trường hợp biên#
- Throw trong hook cấp thấp làm treo luồng.
agentLoop()chạyrunAgentLoop(...).then(...)mà không cócatch(agent-loop.ts:56–67). NếuconvertToLlmhaytransformContextthrow, promise bị reject mà không ai bắt và luồng không bao giờ kết thúc. LớpAgentbắt được trường hợp này nhờhandleRunFailure(). agentLoopContinue()sửa mảng của bạn. Nó chỉ sao chép nông context (agent-loop.ts:153), nên các message mới đượcpushthẳng vàocontext.messagesmà caller truyền vào.agentLoop()thì tạo mảng mới (dòng 121–124).Agentluôn truyền một bản sao nên không bị ảnh hưởng.- Đổi state giữa lần chạy không có hiệu lực ngay. Model, mức suy nghĩ và bộ tool được chụp lúc bắt đầu chạy (
createContextSnapshot(),createLoopConfig()). Gánagent.state.toolstrong lúc chạy chỉ có tác dụng ở lần chạy sau, trừ khi một hook nhưprepareNextTurntrả về context mới; đó chính là việcAgentSessionlàm (agent-session.ts:919–925). - Abort không làm vòng lặp dừng ngay. Vòng lặp chỉ tự kiểm tra tín hiệu quanh các lời gọi tool: khi chuẩn bị, ngay trước khi chạy một closure song song, và sau mỗi tool tuần tự. Nếu huỷ trong lúc tool chạy, nó vẫn phát kết quả, phát
turn_end, rồi gửi request kế tiếp và dựa vào hợp đồng củaStreamFnđể nhận về ngay một message"aborted". MộtStreamFntự viết mà phớt lờsignalsẽ khiến lần chạy tiếp tục. Ở bước đó hàng đợi steer cũng đã bị hỏi (dòng 304), nên một tin steer đang chờ có thể được ghi vào transcript ngay trước câu trả lời bị huỷ. - Lời gọi tool không có kết quả. Huỷ giữa batch tuần tự để lại các lời gọi chưa chạy mà không có toolResult. pi-ai xử lý việc này khi dựng request: nó bỏ qua assistant message bị lỗi hoặc bị huỷ, và chèn kết quả lỗi “No result provided” cho lời gọi mồ côi (transform-messages.ts:179–224).
{ action: "continue" }vô điều kiện là vòng lặp vô tận, vìfinishTurnchạy lại sau request mà chính nó đòi.continue()với đuôi assistant chỉ chạy được khi có tin chờ; nếu không, nó throw “Cannot continue from message role: assistant”. Đuôi là message tuỳ biến thì vòng lặp cấp thấp không kiểm tra được;convertToLlmphải biến nó thànhuserhoặctoolResult, nếu không provider sẽ từ chối (agent-loop.ts:76–78).terminatekhông lưu vào transcript. Nó chỉ là gợi ý lúc chạy; messagetoolResultđược lưu vẫn là kết quả tool chuẩn (README.md:509).- README còn sót cách nói cũ. README viết “LLMs only understand
user,assistant, andtoolResult” và ví dụconvertToLlmcấp thấp lọc bỏ cả rolesystem(README.md:49, :546). Ở v1.4.0 system message mang prompt và khai báo tool, nên bộ lọc như vậy làm mất cả hai; bộ lọc mặc định củaAgentgiữ lạisystem.
Đọc code ở đâu#
| Thứ tự | File | Đọc để hiểu |
|---|---|---|
| 1 | agent/README.md | Chuỗi sự kiện mẫu, các hook, hàng đợi. Đọc trước để có bức tranh. |
| 2 | agent/src/types.ts | Toàn bộ data model và hợp đồng của từng hook, nằm trong chú thích. |
| 3 | agent-loop.ts:172 | runLoop(): hai vòng lặp, chỗ hỏi hàng đợi, lối ra. |
| 4 | agent-loop.ts:390 | Ranh giới AgentMessage → Message và partial message trong context. |
| 5 | agent-loop.ts:518 | Đường ống tool, hai chế độ, hook, terminate, runToolCall(). |
| 6 | agent-loop.ts:342 | Khai báo thay đổi bộ tool bằng system message; đọc cùng ai/src/utils/transcript.ts. |
| 7 | agent/src/agent.ts | createLoopConfig(), processEvents(), continue(), vòng đời một lần chạy. |
| 8 | agent/src/proxy.ts, stream-fn.ts | Lớp vận chuyển: hàm stream mặc định và proxy. |
| 9 | test/agent-loop.test.ts, test/agent.test.ts | Đặc tả chạy được cho thứ tự sự kiện, steer, terminate, khai báo tool. |
| 10 | sdk.ts:403, agent-session.ts:511 | Coding agent cấu hình và bọc Agent thế nào; tiếp tục ở chương AgentSession. |
| 11 | agent/CHANGELOG.md | Lịch sử từng quyết định: steer/follow-up, hook, chế độ song song, finishTurn. |
Tóm tắt#
- pi-agent-core là một vòng lặp agent nhỏ (khoảng 2.500 dòng) trên pi-ai: gọi mô hình, chạy batch tool, lặp cho tới khi mô hình thôi gọi tool. Không có max steps.
- Transcript là
AgentMessage[], mở rộng được bằng declaration merging; chỉ ở ranh giới gọi mô hình nó mới thànhMessage[]quatransformContextvàconvertToLlm. - Vận chuyển chỉ là một hàm
StreamFnkhông được throw;streamProxy()là một cài đặt qua server. - Mỗi tool call đi qua chuẩn bị, chạy, hoàn tất; chế độ song song chuẩn bị tuần tự, chạy đồng thời và ghi transcript theo thứ tự nguồn. Lời gọi từ message bị cắt cụt (
"length") không bao giờ chạy. - Tin steer được chèn sau batch tool của lượt hiện tại, follow-up chỉ khi agent sắp dừng; cả hai mặc định
one-at-a-time. - Bộ tool chạy được và bộ tool mô hình thấy được giữ khớp nhau bằng system message
toolsAdded/toolsRemovedtrong transcript, để thay đổi giữa hội thoại không phá prompt cache. - Lớp
Agentdựng state từ sự kiện, await listener như rào chắn, và cung cấpprompt(),continue(),steer(),followUp(),abort(),waitForIdle(),reset().
7AgentSession: vòng đời một prompt#
Vai trò#
Agent của pi-agent-core chỉ biết chạy vòng lặp agent (agent loop) trên một mảng message nằm trong bộ nhớ: gửi yêu cầu, nhận luồng (stream) phản hồi, chạy tool, lặp lại cho đến khi mô hình thôi gọi tool (xem chương pi-agent-core). Nó không biết tệp session, settings, extension hay lệnh /compact. Một coding agent dùng hằng ngày cần nhiều hơn: mỗi message phải được ghi xuống đĩa ngay khi hoàn tất, prompt phải được mở rộng (prompt template, skill), lỗi provider (nhà cung cấp) tạm thời phải được thử lại, ngữ cảnh gần đầy phải được nén, đổi mô hình (model) phải được ghi lại để lần mở phiên sau khôi phục đúng.
AgentSession trong packages/coding-agent/src/core/agent-session.ts là lớp làm tất cả những việc đó. Chú thích đầu tệp liệt kê đúng phạm vi của nó:
/** * AgentSession - Core abstraction for agent lifecycle and session management. * * This class is shared between all run modes (interactive, print, rpc). * It encapsulates: * - Agent state access * - Event subscription with automatic session persistence * - Model and thinking level management * - Compaction (manual and auto) * - Bash execution * - Session switching and branching * * Modes use this class and add their own I/O layer on top. */Ai gọi nó? Mọi chế độ chạy trong chương các chế độ: interactive gọi session.prompt(text) khi người dùng nhấn Enter (vòng lặp chính ở interactive-mode.ts:1253), RPC mode chuyển lệnh JSON thành các lời gọi tương tự, print mode gửi một prompt rồi thoát, còn SDK trả thẳng đối tượng AgentSession cho mã của bạn. Ba cộng sự cốt lõi được giữ dưới dạng trường readonly: agent, sessionManager, settingsManager (agent-session.ts:379).
Ngoài ba trường đó, nó giữ ModelRuntime (tra mô hình và credential), ResourceLoader (extension, skill, prompt template, context file), ExtensionRunner (phát sự kiện cho extension) và sổ đăng ký tool. Vị trí của nó trong cả hệ thống:
/new, /resume, /fork.!.Bên cạnh trục dọc đó là SessionManager (tệp JSONL, chương Session), SettingsManager, ExtensionRunner (chương Extensions) và các tool cùng system prompt (chương Công cụ).
Mô hình tư duy#
Có một câu đủ để giữ trong đầu suốt chương: tệp session là bản gốc, mảng message của Agent chỉ là bản chiếu. AgentSession nối hai bên theo hai chiều. Chiều đi lên: nó đăng ký nghe mọi sự kiện (event) của Agent và ghi từng message đã hoàn tất thành một entry trong session. Chiều đi xuống: nó cài các hook vào Agent để ngay trước mỗi yêu cầu gửi tới mô hình, ngữ cảnh được dựng lại từ session, chứ không lấy mảng trong bộ nhớ của Agent.
Chế độ chạy ── prompt(text) / steer / followUp / abort / executeBash
│
┌───────▼──────────────┐ subscribe(_handleAgentEvent) ┌──────────────────────┐
│ AgentSession │◄──────── sự kiện của Agent ──────│ Agent │
│ │ │ (pi-agent-core) │
│ hook cài lúc khởi │── prepareRequest ────────────────►│ vòng lặp agent: │
│ tạo (constructor): │── prepareNextTurnWithContext ────►│ lượt → tool → lượt │
│ │── finishTurn ────────────────────►│ │
│ │── beforeToolCall / afterToolCall ►│ │
│ │── transformContext (2 lớp) ──────►│ │
└──┬────────┬───────┬──┘ └──────────┬───────────┘
│append*()│emit() │get*() │ streamFn
▼ ▼ ▼ ▼
SessionManager ExtensionRunner SettingsManager ModelRuntime → provider
(tệp JSONL)
Các hook được cài đúng một lần trong constructor. Chúng đọc this._extensionRunner lúc chạy, nên /reload thay runner mới mà không phải cài lại hook:
// Always subscribe to agent events for internal handling// (session persistence, extensions, auto-compaction, retry logic)this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);this._installAgentToolHooks();this._installAgentNextTurnRefresh();this._installAgentRequestProjection();this._installAgentBoundaryHooks();this._installHiddenDeclarationsProjection();this._installAgentForcedPromptProjection();this._buildRuntime({ activeToolNames: this._initialActiveToolNames, includeAllExtensionTools: true,});if (this._usesDefaultTools) this._restoreToolsFromTranscript(this._defaultToolModifiers);_buildRuntime() tạo định nghĩa tool, ExtensionRunner và sổ đăng ký tool; _restoreToolsFromTranscript() khôi phục bộ tool mà nhánh session đã ghi lại.Từ vựng dùng trong chương:
- lần chạy (run)
- Từ
_startRun()đến sự kiệnagent_settled. Một lần chạy có thể gọiagent.prompt()một lần rồiagent.continue()nhiều lần (sau retry, sau compaction, khi còn tin nhắn xếp hàng). - lượt (turn)
- Một phản hồi assistant cùng các kết quả tool của nó, kết thúc bằng sự kiện
turn_end. - entry
- Một dòng JSON trong tệp session, có
idvàparentId(chương Session). - projection
- Kết quả
sessionManager.buildSessionProjection(): danh sách message mô hình sẽ thấy, đi từ gốc tới lá (leaf) của nhánh đang hoạt động, đã áp dụng compaction và context edit, mỗi message kèm entry nguồn. - boundary
- Hai điểm dừng
turn_endvàagent_before_settle, nơi extension có thể ghi thêm entry và xin chạy tiếp.
Data model: cấu hình, sự kiện, message#
AgentSessionConfig: những gì cần để dựng một phiên
Constructor nhận một đối tượng cấu hình. Các trường bắt buộc cho thấy những cộng sự chính đều đến từ bên ngoài: Agent, SessionManager, SettingsManager, ResourceLoader và ModelRuntime do bên gọi (thường là createAgentSession() trong sdk.ts) truyền vào. Chỉ ExtensionRunner và sổ đăng ký tool được dựng bên trong, trong _buildRuntime() (agent-session.ts:3666), vì /reload phải dựng lại chúng.
export interface AgentSessionConfig { agent: Agent; sessionManager: SessionManager; settingsManager: SettingsManager; cwd: string; /** Models to cycle through with Ctrl+P (from --models flag) */ scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>; /** Resource loader for extensions, skills, prompts, themes, context files, and system prompt */ resourceLoader: ResourceLoader; /** SDK custom tools registered outside extensions */ customTools?: ToolDefinition[]; /** Canonical model/auth runtime used by coding-agent internals. */ modelRuntime: ModelRuntime; /** Keeps the prompt cache entry of the last session request warm. */ cacheWarmer?: Pick<CacheWarmer, "cancel" | "status" | "onAgentSettled" | "onModeChanged" | "onWarmed">; /** Initial active built-in tool names. Default: [read, bash, edit, write] */ initialActiveToolNames?: string[]; /** Mutable ref used by Agent to access the current ExtensionRunner */ extensionRunnerRef?: { current?: ExtensionRunner }; /** Session start event metadata emitted when extensions bind to this runtime. */ sessionStartEvent?: SessionStartEvent;}usesDefaultTools, allowedToolNames, excludedToolNames, baseToolsOverride; chúng thuộc chương Công cụ.AgentSessionEvent: sự kiện công khai
Người dùng của AgentSession (TUI, RPC, SDK) đăng ký bằng session.subscribe(listener). Họ nhận mọi sự kiện của Agent cộng với các sự kiện riêng của phiên:
export type AgentSessionEvent = | WithParentToolCallId<Exclude<AgentEvent, { type: "agent_end" }>> | { type: "agent_end"; messages: AgentMessage[]; willRetry: boolean; } | { type: "agent_settled"; aborted: boolean } | { type: "queue_update"; steering: readonly string[]; followUp: readonly string[]; } | { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" } | { type: "entry_appended"; entry: SessionEntry } | { type: "session_info_changed"; name: string | undefined } | { type: "thinking_level_changed"; level: ThinkingLevel } | { type: "compaction_end"; reason: "manual" | "threshold" | "overflow"; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean; errorMessage?: string; } | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string } | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string } | { type: "bash_execution_update"; id?: string; delta: string };agent_end được bọc lại để thêm willRetry: UI biết lần dừng này là tạm thời. agent_settled mới là điểm phiên thực sự rảnh. entry_appended báo các entry ghi ngoài luồng message (context edit, trạng thái router, entry của extension).Sự kiện được phát cho extension trước, rồi mới tới listener công khai (agent-session.ts:1142). Thứ tự này có lý do, sẽ thấy ở bước 3.
PromptOptions
/** Options for AgentSession.prompt() */export interface PromptOptions { /** Whether to dispatch extension commands and expand skill commands and prompt templates (default: true) */ expandPromptTemplates?: boolean; /** Image attachments */ images?: ImageContent[]; /** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). Required if streaming. */ streamingBehavior?: "steer" | "followUp"; /** Source of input for extension input event handlers. Defaults to "interactive". */ source?: InputSource; /** Internal hook used by RPC mode to observe how an accepted prompt was dispatched. Not called if the prompt is rejected. */ preflightResult?: (disposition: PromptDisposition) => void;}streamingBehavior là bắt buộc khi agent đang chạy: không có nó, prompt() ném lỗi thay vì đoán người dùng muốn steer hay follow-up.Bốn role message riêng của coding agent
pi-ai chỉ biết bốn role mà provider hiểu: system, user, assistant, toolResult. Coding agent cần thêm các loại message mà UI hiển thị khác đi nhưng vẫn phải vào ngữ cảnh. Nó khai báo chúng bằng declaration merging trên interface CustomAgentMessages của pi-agent-core (docs/message-types.md):
/** * Message type for bash executions via the ! command. */export interface BashExecutionMessage { role: "bashExecution"; command: string; output: string; exitCode: number | undefined; cancelled: boolean; truncated: boolean; fullOutputPath?: string; timestamp: number; /** If true, this message is excluded from LLM context (!! prefix) */ excludeFromContext?: boolean;}/** * Message type for extension-injected messages via sendMessage(). * These are custom messages that extensions can inject into the conversation. */export interface CustomMessage<T = unknown> { role: "custom"; customType: string; content: string | (TextContent | ImageContent)[]; display: boolean; details?: T; timestamp: number;}bashExecution sinh ra từ lệnh ! của người dùng; custom do extension gửi qua pi.sendMessage(). display chỉ điều khiển việc vẽ; details không bao giờ gửi cho mô hình.export interface BranchSummaryMessage { role: "branchSummary"; summary: string; fromId: string | null; timestamp: number;}export interface CompactionSummaryMessage { role: "compactionSummary"; summary: string; tokensBefore: number; timestamp: number;}// Extend CustomAgentMessages via declaration mergingdeclare module "@earendil-works/pi-agent-core" { interface CustomAgentMessages { bashExecution: BashExecutionMessage; custom: CustomMessage; branchSummary: BranchSummaryMessage; compactionSummary: CompactionSummaryMessage; }}message; chúng được sinh lại từ entry compaction và branch_summary mỗi lần dựng ngữ cảnh (chương Session).Trước khi gửi, convertToLlm() hạ cấp các role riêng về user. Hàm này được truyền vào Agent trong sdk.ts và được vòng lặp agent gọi một lần cho mỗi yêu cầu:
export function convertToLlm(messages: AgentMessage[]): Message[] { return messages .map((m): Message | undefined => { switch (m.role) { case "bashExecution": // Skip messages excluded from context (!! prefix) if (m.excludeFromContext) { return undefined; } return { role: "user", content: [{ type: "text", text: bashExecutionToText(m) }], timestamp: m.timestamp, }; case "custom": { const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content; return { role: "user", content, timestamp: m.timestamp, }; } case "branchSummary": return { role: "user", content: [{ type: "text" as const, text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX }], timestamp: m.timestamp, }; case "compactionSummary": return { role: "user", content: [ { type: "text" as const, text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX }, ], timestamp: m.timestamp, };!! đặt excludeFromContext nên bị bỏ hẳn. Các nhánh còn lại (dòng 184 đến 188) trả nguyên system, user, assistant, toolResult.Tóm tắt compaction và tóm tắt nhánh được bọc trong thẻ <summary> với câu dẫn cố định (messages.ts:11). sdk.ts còn bọc thêm một lớp: khi setting images.blockImages bật, mọi ảnh trong message user và toolResult được thay bằng dòng chữ "Image reading is disabled." (sdk.ts:296), và setting được đọc lại mỗi lần nên đổi giữa phiên có hiệu lực ngay.
Dựng một phiên: services, SDK và runtime#
Việc dựng một phiên chia làm ba tầng, mỗi tầng một tệp. Tách như vậy vì có những thứ phụ thuộc vào thư mục làm việc (cwd): settings dự án, extension dự án, provider do extension đăng ký. Khi /resume mở một session của dự án khác, cwd đổi, và mọi thứ gắn với cwd phải được tạo lại.
- Services theo cwd
createAgentSessionServices()tạoModelRuntime,SettingsManager,DefaultResourceLoadercho một cwd. - Phiên
createAgentSession()trongsdk.tschọn mô hình, mức suy nghĩ (thinking level), tạoAgentvàAgentSession. - Runtime
AgentSessionRuntimegiữ phiên hiện tại và thay nó khi người dùng đổi phiên.
Tầng 1: services gắn với cwd
/** * Coherent cwd-bound runtime services for one effective session cwd. * * This is infrastructure only. The AgentSession itself is created separately so * session options can be resolved against these services first. */export interface AgentSessionServices { cwd: string; agentDir: string; modelRuntime: ModelRuntime; settingsManager: SettingsManager; resourceLoader: ResourceLoader; diagnostics: AgentSessionRuntimeDiagnostic[];}export async function createAgentSessionServices( options: CreateAgentSessionServicesOptions,): Promise<AgentSessionServices> { const cwd = resolvePath(options.cwd); const agentDir = options.agentDir ? resolvePath(options.agentDir) : getAgentDir(); const modelRuntime = options.modelRuntime ?? (await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json"), signal: options.modelRuntimeSignal, })); const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); const resourceLoader = new DefaultResourceLoader({ ...(options.resourceLoaderOptions ?? {}), cwd, agentDir, settingsManager, }); await resourceLoader.reload(options.resourceLoaderReloadOptions); await modelRuntime.refresh({ allowNetwork: false }); diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues)); return { cwd, agentDir, modelRuntime, settingsManager, resourceLoader, diagnostics, };}allowNetwork: false: khởi động không chờ mạng.Tầng 2: createAgentSession() khôi phục và tạo Agent
Nếu SessionManager đã có dữ liệu (đang --continue hay --resume), mô hình được khôi phục từ nhánh session, với điều kiện provider của nó vẫn có credential:
// Check if session has existing data to restoreconst existingSession = sessionManager.buildSessionContext();const hasExistingSession = existingSession.messages.length > 0;const hasThinkingEntry = sessionManager.getBranch().some((entry) => entry.type === "thinking_level_change");let model = options.model;let modelFallbackMessage: string | undefined;// Assistant messages name the physical model that answered, so a virtual selection is only in// model_change entries.const sessionModel = getBranchSelection(sessionManager.getBranch(), (provider, modelId) => modelRuntime.getModel(provider, modelId),);// If session has data, try to restore model from itif (!model && hasExistingSession && sessionModel) { const restoredModel = modelRuntime.getModel(sessionModel.provider, sessionModel.modelId); if (restoredModel && modelRuntime.hasConfiguredAuth(restoredModel.provider)) { model = restoredModel; } if (!model) { modelFallbackMessage = `Could not restore model ${sessionModel.provider}/${sessionModel.modelId}`; }}findInitialModel() chọn theo settings rồi theo provider có credential (mục dịch vụ). Mức suy nghĩ đi theo thứ tự tương tự ở sdk.ts:253, và cuối cùng bị kẹp (clamp) theo khả năng của mô hình.Rồi Agent được tạo. Bốn điểm nối quan trọng nằm gọn trong lời gọi này:
const agent = new Agent({ initialState: { systemPrompt: "", model, thinkingLevel, tools: [], messages: existingSession.messages, }, convertToLlm: convertToLlmWithBlockImages, 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); }, onPayload: transformProviderPayload, onResponse: handleProviderResponse, onProviderStreamEvent: handleProviderStreamEvent, sessionId: sessionManager.getSessionId(), transformContext: async (messages) => { const runner = extensionRunnerRef.current; if (!runner) return messages; return runner.emitContext(messages); }, steeringMode: settingsManager.getSteeringMode(), followUpMode: settingsManager.getFollowUpMode(), transport: settingsManager.getTransport(), thinkingBudgets: settingsManager.getThinkingBudgets(), maxRetryDelayMs: settingsManager.getProviderRetrySettings().maxRetryDelayMs,});systemPrompt để trống: ở v1.4.0 system prompt nằm trong transcript dưới dạng message system (bước 2). messages lấy từ buildSessionContext(). streamFn đi qua ModelRuntime.streamSimple(), nơi credential được giải quyết cho từng yêu cầu. transformContext là sự kiện context của extension.Một phiên mới được ghi sẵn mô hình và mức suy nghĩ ban đầu, để lần resume sau khôi phục đúng:
// Restore missing settings metadata for older sessions.if (hasExistingSession) { if (!hasThinkingEntry) { sessionManager.appendThinkingLevelChange(thinkingLevel); }} else { // Save initial model and thinking level for new sessions so they can be restored on resume if (model) { sessionManager.appendModelChange(model.provider, model.id); } sessionManager.appendThinkingLevelChange(thinkingLevel);}Hai entry này chưa chạm đĩa: SessionManager chỉ tạo tệp khi có message user hoặc assistant đầu tiên (chương Session). Mở pi rồi thoát ngay không để lại tệp rỗng.
Tầng 3: AgentSessionRuntime thay phiên
AgentSession không tự chuyển sang tệp khác. Đổi phiên nghĩa là huỷ phiên cũ và dựng phiên mới bằng một factory do main.ts cung cấp (main.ts:733):
private async teardownCurrent(reason: SessionShutdownEvent["reason"], targetSessionFile?: string): Promise<void> { // Settle any active response first so the aborted turn (including tool // results) is persisted to the outgoing session before it is replaced. await this.session.abort(); await emitSessionShutdownEvent(this.session.extensionRunner, { type: "session_shutdown", reason, targetSessionFile, }); this.beforeSessionInvalidate?.(); this.session.dispose();} const previousSessionFile = this.session.sessionFile; const sessionManager = SessionManager.open(sessionPath, undefined, options?.cwdOverride); assertSessionCwdExists(sessionManager, this.cwd); await this.teardownCurrent("resume", sessionManager.getSessionFile()); this.apply( await this.createRuntime({ cwd: sessionManager.getCwd(), agentDir: this.services.agentDir, sessionManager, sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()), }), ); await this.finishSessionReplacement(options?.withSession); return { cancelled: false };}abort() chạy trước để lượt đang dở (kể cả kết quả tool) được ghi vào phiên cũ trước khi nó bị thay. Sau đó extension nhận session_shutdown, phiên cũ bị dispose(), và factory tạo services cho cwd của session đích.newSession(), fork() và importFromJsonl() đi theo cùng khuôn đó (agent-session-runtime.ts:226); chi tiết fork nằm ở chương Session. Mọi ctx mà extension giữ từ phiên cũ trở thành stale: dispose() gọi invalidate() với thông báo hướng dẫn dùng withSession (agent-session.ts:1423).
Bước 1: prompt() xử lý đầu vào#
Phần còn lại của chương lần theo một prompt từ lúc người dùng gõ đến lúc phiên rảnh trở lại. Bước đầu tiên xảy ra trước cả AgentSession, và chỉ áp dụng cho dòng lệnh.
Đối số @file trên dòng lệnh
Với pi @src/app.ts "giải thích tệp này", main.ts đọc tệp trước khi tạo prompt đầu tiên. Tệp văn bản được nhúng nguyên văn trong thẻ <file>; tệp ảnh trở thành ImageContent đính kèm, còn văn bản chỉ giữ một thẻ <file> rỗng (hoặc chứa gợi ý về việc xử lý ảnh) làm chỗ tham chiếu:
const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);if (mimeType) { // Handle image file const content = await readFile(absolutePath); const processed = await processImage(content, mimeType, { autoResizeImages }); if (!processed.ok) { text += `<file name="${absolutePath}">${processed.message}</file>\n`; continue; } const attachment: ImageContent = { type: "image", mimeType: processed.mimeType, data: processed.data, }; images.push(attachment); // Add text reference to image with optional processing hints if (processed.hints.length > 0) { text += `<file name="${absolutePath}">${processed.hints.join("\n")}</file>\n`; } else { text += `<file name="${absolutePath}"></file>\n`; }} else { // Handle text file try { const content = stripBom(await readFile(absolutePath, "utf-8")); text += `<file name="${absolutePath}">\n${content}\n</file>\n`;main.ts gọi hàm này với autoResizeImages: false (main.ts:222): việc thu nhỏ ảnh để AgentSession làm sau, khi extension đã có cơ hội đổi mô hình.Trong trình soạn thảo của interactive mode, gõ @ chỉ mở gợi ý đường dẫn và chèn chuỗi @path vào văn bản (autocomplete.ts:443); nội dung tệp không được đọc sẵn, mô hình tự dùng tool read nếu cần.
prompt(): bốn cửa trước khi chạy
async prompt(text: string, options?: PromptOptions): Promise<void> { if (this._isEmittingAgentSettled) { this._deferredSettledActions.push(async () => await this.prompt(text, options)); return; } const expandPromptTemplates = options?.expandPromptTemplates ?? true; const preflightResult = options?.preflightResult; // Handle extension commands first (execute immediately, even during streaming) // Extension commands manage their own LLM interaction via pi.sendMessage() if (expandPromptTemplates && text.startsWith("/")) { const handled = await this._tryExecuteExtensionCommand(text); if (handled) { // Extension command executed, no prompt to send preflightResult?.("handled"); return; } } if (this._compactionAbortController !== undefined) { throw new Error( "Cannot submit a prompt while compaction is in progress. Wait for compaction to finish and retry.", ); } // Emit input event for extension interception (before skill/template expansion) const processedInput = await this._runInputHandlers( text, options?.images, options?.source ?? "interactive", this.isStreaming ? options?.streamingBehavior : undefined, ); if (!processedInput) { preflightResult?.("handled"); return; } const { text: currentText, images: currentImages } = processedInput; // Expand skill commands (/skill:name args) and prompt templates (/template args) let expandedText = currentText; if (expandPromptTemplates) { expandedText = this._expandSkillCommand(expandedText); expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); }/tên do pi.registerCommand đăng ký) chạy ngay cả khi agent đang bận, trước kiểm tra compaction. Sự kiện input cho extension xem văn bản thô, trước khi skill và template được mở rộng.Đi theo từng cửa:
- Đang phát agent_settled?Nếu một listener của
agent_settledgọiprompt(), lời gọi được hoãn vào_deferredSettledActionsvà chạy sau khi sự kiện phát xong (dòng 2093 đến 2096). - Lệnh extensionVăn bản bắt đầu bằng
/được tra trong các lệnh extension. Tìm thấy thì chạy handler với command context và dừng; lỗi của handler được báo quaemitErrorchứ không ném ra (agent-session.ts:2180). - Sự kiện inputExtension có thể trả
handled(nuốt input),transform(thay văn bản, ảnh) hoặc để nguyên (agent-session.ts:2041). - Skill và prompt template
/skill:tên argsđược thay bằng nội dung SKILL.md;/tên argskhớp một prompt template được thay bằng template đã điền tham số.
private _expandSkillCommand(text: string): string { if (!text.startsWith("/skill:")) return text; const spaceIndex = text.indexOf(" "); const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex); const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim(); const skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName); if (!skill) return text; // Unknown skill, pass through try { const content = readFileSync(skill.filePath, "utf-8"); const body = stripFrontmatter(content).trim(); const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${skill.baseDir}.\n\n${body}\n</skill>`; return args ? `${skillBlock}\n\n${args}` : skillBlock;<skill> có đường dẫn tệp. TUI nhận ra khối này nhờ parseSkillBlock() (agent-session.ts:177) để vẽ gọn lại. Skill không tồn tại thì văn bản đi qua nguyên vẹn.Prompt template đơn giản hơn: expandPromptTemplate() tách /tên khỏi phần tham số, tìm template trùng tên, và nếu có thì trả về nội dung template đã thay $1, $@, ${1:-mặc định}... bằng các tham số (prompt-templates.ts:309, substituteArgs()). Không khớp template nào thì văn bản giữ nguyên. Cách khai báo template thuộc chương Extensions.
Nửa sau quyết định giữa xếp hàng và bắt đầu một lần chạy:
// If streaming, queue via steer() or followUp() based on option if (this.isStreaming) { if (!options?.streamingBehavior) { throw new Error( "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.", ); } if (options.streamingBehavior === "followUp") { await this._queueFollowUp(expandedText, currentImages); } else { await this._queueSteer(expandedText, currentImages); } preflightResult?.("queued"); return; } // Validate model if (!this.model) { throw new Error(formatNoModelSelectedMessage()); } const hasConfiguredAuth = this._modelRuntime.hasConfiguredAuth(this.model.provider) || (await this._modelRuntime.checkAuth(this.model.provider)) !== undefined; if (!hasConfiguredAuth) { const isOAuth = this._modelRuntime.isUsingOAuth(this.model.provider); if (isOAuth) { throw new Error( `Authentication failed for "${this.model.provider}". ` + `Credentials may have expired or network is unavailable. ` + `Run '/login ${this.model.provider}' to re-authenticate.`, ); } throw new Error(formatNoApiKeyFoundMessage(this.model.provider)); } await this._startRun({ messageType: "user", prompt: expandedText, images: currentImages }, () => preflightResult?.("started"), );}_startRun().Bước 2: chuẩn bị và chạy một lần chạy#
_startRun() là khung của mọi lần chạy, dù khởi đầu từ prompt của người dùng hay từ sendCustomMessage(..., { triggerTurn: true }) của extension. Nửa đầu chuẩn bị:
private async _startRun(trigger: BeforeAgentStartTrigger, onStarted?: () => void): Promise<void> { this._isAgentRunActive = true; this._agentRunAbortRequested = false; let messages: AgentMessage[]; try { messages = await this._prepareRun(trigger); } catch (error) { this._isAgentRunActive = false; this._flushPendingBashMessages(); this._flushPendingCustomMessages(); this._resolveIdleWaitIfIdle(); throw error; } // Messages held while the run was starting go before its starting messages, as they would // have while idle. No run exists yet, so they cannot split a tool call from its result. this._flushPendingBashMessages(); this._flushPendingCustomMessages(); onStarted?.(); // Compaction before the prompt may have scheduled a retry; the new prompt replaces it. this._failedResponse = undefined; this._recordSelection(); // The run records the loadout in the transcript; restored tools that did not register by now // are dropped, so a tool that never registers does not stay pending. this._pendingToolNames.clear();before_agent_start. Nhờ vậy một tin nhắn gửi tới trong lúc handler của extension đang chạy được xếp vào lần chạy này thay vì mở lần chạy thứ hai, và một lệnh abort lúc đó sẽ huỷ lần chạy ngay khi nó tồn tại.Nửa sau là vòng lặp sau run: agent.prompt() chạy vòng lặp agent cho tới agent_end; rồi AgentSession tự hỏi có cần chạy tiếp không.
try { await this.agent.prompt(messages); while (!this._agentRunAbortRequested) { if (await this._handlePostAgentRun()) { if (this._agentRunAbortRequested) break; await this.agent.continue(); continue; } if (this._agentRunAbortRequested || !(await this._runBeforeSettleBoundary())) break; if (this._agentRunAbortRequested) break; await this.agent.continue(); }} finally { if (this._agentRunAbortRequested) this._finishCancelledRetry(); this._failedResponse = undefined; this._runSystemPromptOptions = undefined; this._flushPendingBashMessages(); this._flushPendingCustomMessages(); await this._emitAgentSettled();}agent.continue() tiếp tục từ transcript hiện có, không thêm message mới. Khối finally luôn đổ các message bash và custom còn treo rồi phát agent_settled, kể cả khi lần chạy bị abort._prepareRun(): before_agent_start và message khởi đầu
private async _prepareRun(trigger: BeforeAgentStartTrigger): Promise<AgentMessage[]> { // Check if we need to compact before sending (catches aborted responses). // The starting message is sent below, so do not call agent.continue() here. const lastAssistant = this._findLastAssistantMessage(); if (lastAssistant) { await this._checkCompaction(lastAssistant, false); } const selectedToolsBefore = this._baseSystemPromptOptions.selectedTools; const result = await this._extensionRunner.emitBeforeAgentStart(trigger, this._baseSystemPromptOptions); // Handlers may edit event.systemPromptOptions.selectedTools or call setActiveTools(), // which updates the live loadout instead. An explicit edit wins; otherwise the live // loadout is authoritative, so a setActiveTools() call is not undone here. const handlerEditedTools = result.systemPromptOptions.selectedTools.length !== selectedToolsBefore.length || result.systemPromptOptions.selectedTools.some((name, index) => name !== selectedToolsBefore[index]); if (!handlerEditedTools) result.systemPromptOptions.selectedTools = this.getActiveToolNames();_checkCompaction(lastAssistant, false) xét cả phản hồi bị abort: nếu lượt trước bị người dùng ngắt khi ngữ cảnh đã gần đầy, compaction chạy trước khi gửi prompt mới. Handler before_agent_start nhận bản sao các tuỳ chọn system prompt và có thể sửa chúng hoặc chèn message (chương Extensions).const messages: AgentMessage[] = [];if (trigger.messageType === "custom") { messages.push(trigger.message);} else { // Normalize images after before_agent_start so extension-driven model selection // determines the resize profile used for the request and history. const normalized = await this._normalizePromptImages(trigger.images); const text = normalized.hints.length > 0 ? `${trigger.prompt}\n\n${normalized.hints.join("\n")}` : trigger.prompt; messages.push({ role: "user", content: [{ type: "text", text }, ...normalized.images], timestamp: Date.now(), });}// Inject any pending "nextTurn" messages as context alongside the starting messagemessages.push(...this._pendingNextTurnMessages);this._pendingNextTurnMessages = [];for (const msg of result.messages) { messages.push({ role: "custom", customType: msg.customType, // Untyped extensions can pass null/missing content; normalize at ingestion. content: msg.content ?? [], display: msg.display, details: msg.details, timestamp: Date.now(), });}const updateMessage = this._preparePromptAndToolLoadout(result.systemPromptOptions);this._runSystemPromptOptions = result.systemPromptOptions;if (updateMessage) messages.unshift(updateMessage);return messages;before_agent_start, vì extension có thể đã đổi mô hình và giới hạn kích thước ảnh đi theo mô hình. Lỗi xử lý ảnh không chặn prompt: nó thành dòng gợi ý nối vào văn bản.Kết quả là một mảng theo thứ tự: [bản vá system nếu prompt thay đổi], message user, các message "nextTurn" đang chờ, các message custom do handler trả về.
System prompt là message trong transcript
Ở v1.4.0 system prompt không phải một chuỗi riêng truyền theo mỗi yêu cầu. Nó được chia thành các phần có tên (sections) và ghi vào transcript dưới dạng message system. Message đầu tiên mang mọi phần; các message sau chỉ mang phần đã đổi (docs/session-format.md:80). _preparePromptAndToolLoadout() tạo bản vá đó:
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;}getCurrentSystemMessage() của pi-ai phát lại (replay) mọi message system trong transcript thành một trạng thái hiện tại (transcript.ts:73); diffSystemPromptSections() trả undefined khi không có gì đổi (system-prompt.ts:217). Nội dung từng phần (preamble, tools, skills, context file) thuộc chương Công cụ và system prompt.Tool đi theo cùng cơ chế. Ngay trước khi phát các message khởi đầu, vòng lặp agent so bộ tool đã khai báo trong transcript với bộ tool thực thi được và ghi chênh lệch vào toolsAdded/toolsRemoved của message system (agent-loop.ts:342). Vì vậy prompt đầu tiên của một phiên mới ghi một message system chứa mọi phần prompt và mọi khai báo tool; prompt thứ hai, nếu không có gì đổi, không ghi message system nào.
Một ngoại lệ: handler before_agent_start trả về systemPrompt muốn đúng chuỗi đó đứng đầu yêu cầu. Chuỗi ép buộc này không ghi vào transcript; nó được chiếu lên yêu cầu bằng một lớp transformContext gộp mọi message system thành một đầu duy nhất (agent-session.ts:1807). Lớp còn lại AgentSession cài lên transformContext bỏ các khai báo tool mà hook prepareLoadout muốn ẩn (agent-session.ts:1787). Cả hai chạy sau sự kiện context của extension, lớp do sdk.ts cài.
_handlePostAgentRun(): có chạy tiếp không
private async _handlePostAgentRun(): Promise<boolean> { const message = this._lastAssistantMessage; this._lastAssistantMessage = undefined; if (this._agentRunAbortRequested) { this._finishCancelledRetry(); return false; } if (!message) return this.agent.hasQueuedMessages(); if (this._isRetryableError(message) && (await this._prepareRetry(message))) { if (this._agentRunAbortRequested) this._finishCancelledRetry(); this._failedResponse = message; return !this._agentRunAbortRequested; } if (this._agentRunAbortRequested) { this._finishCancelledRetry(); return false; } if (message.stopReason === "error" && this._retryAttempt > 0) { this._emit({ type: "auto_retry_end", success: false, attempt: this._retryAttempt, finalError: message.errorMessage, }); this._retryAttempt = 0; } if (await this._checkCompaction(message)) { return !this._agentRunAbortRequested; } // The low-level loop drains both queues before agent_end. Messages queued by // agent_end handlers require a fresh run before pre-settlement handlers fire. return !this._agentRunAbortRequested && this.agent.hasQueuedMessages();}agent.continue(): lỗi có thể thử lại (retry), compaction do tràn ngữ cảnh cần thử lại lượt (compaction), hoặc còn tin nhắn xếp hàng do handler agent_end thêm vào.Khi không còn lý do nào, _runBeforeSettleBoundary() phát boundary agent_before_settle: extension có thể ghi entry (một custom message, một context edit, thậm chí một compaction) và xin chạy tiếp. Lời xin chỉ được chấp nhận khi ngữ cảnh sau khi ghi còn chạy tiếp được: có message ngoài system và message cuối không phải assistant, hoặc vẫn còn message đang chờ (hàng đợi steer/follow-up, custom message bị hoãn) (agent-session.ts:999).
Bước 3: sự kiện và ghi session#
Trong lúc vòng lặp agent chạy, mọi sự kiện đi qua một hàm duy nhất, _handleAgentEvent. Phần đầu của nó làm ba việc trước khi ghi bất cứ thứ gì:
private _handleAgentEvent = async (event: AgentEvent): Promise<void> => { // An abort requested while the run was starting, e.g. during before_agent_start, stops it // here. agent_start is awaited before the first request. if (event.type === "agent_start" && this._agentRunAbortRequested) this.agent.abort(); // When a user message starts, check if it's from either queue and remove it BEFORE emitting // This ensures the UI sees the updated queue state if (event.type === "message_start" && event.message.role === "user") { this._overflowRecoveryAttempted = false; const messageText = contentText(event.message.content, ""); if (messageText) { // Check steering queue first const steeringIndex = this._steeringMessages.indexOf(messageText); if (steeringIndex !== -1) { this._steeringMessages.splice(steeringIndex, 1); this._emitQueueUpdate(); } else { // Check follow-up queue const followUpIndex = this._followUpMessages.indexOf(messageText); if (followUpIndex !== -1) { this._followUpMessages.splice(followUpIndex, 1); this._emitQueueUpdate(); } } } } // Emit to extensions first, then notify public listeners. await this._emitExtensionEvent(event); this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);user bắt đầu, văn bản của nó được gỡ khỏi danh sách steer hoặc follow-up đang hiển thị, rồi mới phát sự kiện, để UI thấy hàng đợi đã đúng. Phần lược (1107 đến 1119) gắn bản ghi các lời gọi tool lồng nhau (ctx.executeTool()) vào message toolResult.Extension được phát trước listener công khai vì handler message_end của extension có quyền thay message:
} else if (event.type === "message_end") { const extensionEvent: MessageEndEvent = { type: "message_end", message: event.message, }; const replacement = await this._extensionRunner.emitMessageEnd(extensionEvent); if (replacement) { // Untyped extension handlers can return messages with null/missing content; // normalize so it never enters agent state or session history. const normalized = (replacement.role === "user" || replacement.role === "assistant" || replacement.role === "toolResult" || replacement.role === "custom") && replacement.content == null ? ({ ...replacement, content: [] } as AgentMessage) : replacement; this._replaceMessageInPlace(event.message, normalized); }_replaceMessageInPlace() xoá mọi khoá của đối tượng cũ rồi gán khoá của đối tượng mới (agent-session.ts:1286). Agent state, các listener và lệnh ghi session ngay sau đó đều giữ cùng một tham chiếu, nên cả ba thấy bản đã thay.Rồi tới phần ghi. Chỉ message_end dẫn tới ghi; message_start và message_update (từng mẩu văn bản đang stream) chỉ để vẽ.
// Handle session persistenceif (event.type === "message_end") { let entryId: string | undefined; // Check if this is a custom message from extensions if (event.message.role === "custom") { // Persist as CustomMessageEntry entryId = this.sessionManager.appendCustomMessageEntry( event.message.customType, event.message.content, event.message.display, event.message.details, ); } else if ( event.message.role === "system" || event.message.role === "user" || event.message.role === "assistant" || event.message.role === "toolResult" ) { // Regular LLM message - persist as SessionMessageEntry entryId = this.sessionManager.appendMessage(event.message); } if (entryId) this._entryIdsByMessage.set(event.message, entryId); // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere// A turn ends after its assistant message and every tool result has been appended,// so this is the first point in the run where a context-only custom message can be// inserted without landing between a tool call and its result. Flushing after the// extension and listener dispatch above also picks up messages that turn_end// handlers queued.if (event.type === "turn_end") { this._flushPendingCustomMessages();}custom thành entry custom_message; bốn role của provider thành entry message. bashExecution và hai role tóm tắt không bao giờ đi qua đây. Phần lược (1170 đến 1187) đặt lại bộ đếm retry khi có một phản hồi assistant thành công.Ghi xong không có nghĩa là xong. Các hook cài trên Agent bảo đảm yêu cầu kế tiếp nhìn thấy đúng nội dung tệp session, không phải mảng message trong bộ nhớ. Trước mỗi yêu cầu, prepareRequest thay toàn bộ context.messages bằng projection của session:
private _installAgentRequestProjection(): void { const previousPrepareRequest = this.agent.prepareRequest; this.agent.prepareRequest = async (request, signal) => { const failed = this._failedResponse; this._failedResponse = undefined; const prepare = async () => { const projection = this.sessionManager.buildSessionProjection(); const canonicalContext = { ...request.context, messages: projection.messages, // Messages declare the provider-visible loadout; context.tools keeps executable implementations. tools: this.agent.state.tools.slice(), }; const previous = await previousPrepareRequest?.( { ...request, context: canonicalContext, model: this.agent.state.model, thinkingLevel: this.agent.state.thinkingLevel, }, signal, ); return { previous, context: previous?.context ?? canonicalContext, projection }; }; let { previous, context, projection } = await prepare(); const model = previous?.model ?? this.agent.state.model; const thinkingLevel = previous?.thinkingLevel ?? this.agent.state.thinkingLevel; if (!isVirtualModel(model)) return { ...previous, context, model, thinkingLevel };context_edit hay một compaction giữa hai lượt, yêu cầu sau phản ánh ngay. Phần sau dòng 814 chỉ chạy khi mô hình đang chọn là virtual model: router chọn mô hình vật lý cho riêng yêu cầu này, có thể ghi trạng thái router thành entry custom và có thể kích hoạt compaction theo cửa sổ ngữ cảnh của mô hình được chọn.Giữa hai lượt trong cùng một lần chạy, prepareNextTurnWithContext làm thêm hai việc: kiểm tra ngưỡng compaction và tính lại bản vá system prompt (ví dụ một tool vừa được bật):
this.agent.prepareNextTurnWithContext = async (turn, signal) => { const context = await this._compactBeforeNextAssistantResponse(turn.context); const previousSnapshot = await previousPrepareNextTurnWithContext?.({ ...turn, context }, signal); const nextContext = previousSnapshot?.context ?? context; const runOptions = this._runSystemPromptOptions ?? this._baseSystemPromptOptions; const options = normalizeBuildSystemPromptOptions({ ...runOptions, selectedTools: this.getActiveToolNames(), toolSnippets: { ...this._baseSystemPromptOptions.toolSnippets, ...runOptions.toolSnippets }, toolGuidelines: { ...this._baseSystemPromptOptions.toolGuidelines, ...runOptions.toolGuidelines }, }); const updateMessage = this._preparePromptAndToolLoadout(options, nextContext.messages); // Keep session.systemPrompt and ctx.getSystemPrompt() in step with what the provider sees. this._runSystemPromptOptions = options; return { ...previousSnapshot, context: { ...nextContext, tools: this.agent.state.tools.slice(), }, messages: updateMessage ? [...(previousSnapshot?.messages ?? []), updateMessage] : previousSnapshot?.messages, model: this.agent.state.model, thinkingLevel: this.agent.state.thinkingLevel, };};messages; vòng lặp agent phát nó như message mới trước phản hồi kế tiếp, nên nó cũng đi qua message_end và được ghi thành entry.Ngược lại, khi AgentSession tự ghi entry ngoài luồng sự kiện (sau compaction, retry, điều hướng cây), nó gọi _refreshFinalizedContext() để chép projection mới vào agent.state.messages và nhớ entry nguồn của từng message (agent-session.ts:938).
Entry nào được ghi khi nào
| Thời điểm | Entry | Nơi ghi |
|---|---|---|
| Tạo phiên mới (SDK) | model_change, thinking_level_change | sdk.ts:440 |
--name lúc khởi động | session_info | main.ts:716 |
message_end của system, user, assistant, toolResult | message | agent-session.ts:1158 |
message_end của custom | custom_message | agent-session.ts:1150 |
| Custom message gửi khi rảnh hoặc hoãn tới cuối lượt | custom_message | agent-session.ts:2395 |
Lệnh ! khi rảnh, hoặc sau lần chạy | message (role bashExecution) | agent-session.ts:3957, 3989 |
| Đổi hoặc xoay vòng mô hình | model_change | agent-session.ts:2549, 2616, 2651 |
| Mức suy nghĩ thực sự thay đổi | thinking_level_change | agent-session.ts:2691 |
| Retry hoặc phục hồi sau tràn ngữ cảnh | context_edit với replacement: null | agent-session.ts:1267 |
| Compaction thủ công hoặc tự động | compaction | agent-session.ts:2911, 3237 |
| Router của virtual model đổi trạng thái | custom (pi.virtual-model-state) | agent-session.ts:829 |
Extension gọi pi.appendEntry() | custom | agent-session.ts:3466 |
| Extension ghi ở boundary | custom, custom_message, context_edit, compaction | agent-session.ts:946 |
| Đặt tên phiên | session_info | agent-session.ts:4003 |
| Gắn nhãn | label | agent-session.ts:3480 |
/tree có tóm tắt nhánh | branch_summary | agent-session.ts:4170 |
| Làm ấm prompt cache | usage (kind: "cache_warm") | cache-warmer.ts:342 |
Cấu trúc của từng loại entry nằm ở chương Session.
Lần theo một prompt có một lời gọi tool#
Kịch bản: một phiên mới, mô hình thường (không phải virtual model), không extension nào can thiệp. Người dùng gõ "package.json khai báo version nào?". Mô hình gọi tool read một lần rồi trả lời. Sơ đồ dưới đây ghép ba bước ở trên thành một dòng thời gian; mũi tên trái là sự kiện hoặc lời gọi hook từ Agent, mũi tên phải là lời gọi của AgentSession.
TUI AgentSession Agent (pi-agent-core) SessionManager │ │ │ │ ├─ prompt(text) ─►│ │ │ │ │ lệnh extension? input? skill? │ │ │ │ template? đang chạy? credential? │ │ │ │ _startRun() → _prepareRun(): │ │ │ │ compaction trước prompt? │ │ │ │ before_agent_start │ │ │ ├─ agent.prompt([system, user]) ─────►│ │ │ │ │ declareToolChanges() │ │ │◄─ agent_start, turn_start ──────────┤ │ │ │◄─ message_end(system) ──────────────┤ │ │ ├─ appendMessage(system) ─────────────┼──────────────────────────►│ ① message │ │◄─ message_end(user) ────────────────┤ │ │ ├─ appendMessage(user) ───────────────┼──────────────────────────►│ ② message, tạo tệp │ │◄─ hook prepareRequest ──────────────┤ │ │ ├─ buildSessionProjection() ──────────┼──────────────────────────►│ │ ├─ context.messages = projection ────►│ │ │ │ │ transformContext() │ │ │ │ convertToLlm() │ │ │ │ streamFn → provider │ │◄────────────────┼── message_start, message_update ────┤ │ │ │◄─ message_end(assistant: toolCall) ─┤ │ │ ├─ appendMessage(assistant) ──────────┼──────────────────────────►│ ③ message │ │◄─ hook beforeToolCall: tool_call ───┤ │ │ │ │ tool_execution_start │ │ │ │ chạy tool read │ │ │ │ tool_execution_end │ │ │◄─ hook afterToolCall: tool_result ──┤ │ │ │◄─ message_end(toolResult) ──────────┤ │ │ ├─ appendMessage(toolResult) ─────────┼──────────────────────────►│ ④ message │ │◄─ hook finishTurn: turn_end ────────┤ │ │ │◄─ turn_end ─────────────────────────┤ │ │ │◄─ hook prepareNextTurnWithContext ──┤ │ │ │ ngưỡng compaction, bản vá system │ │ │ │◄─ hook prepareRequest (lần 2) ──────┤ │ │ │ │ streamFn → provider │ │ │◄─ message_end(assistant: văn bản) ──┤ │ │ ├─ appendMessage(assistant) ──────────┼──────────────────────────►│ ⑤ message │ │◄─ turn_end, agent_end ──────────────┤ │ │ │ _handlePostAgentRun(): không │ │ │ │ retry, không compaction, hàng │ │ │ │ đợi rỗng; agent_before_settle │ │ │◄─ agent_settled ┤ │ │
message. Mỗi mũi tên từ Agent được await: Agent cập nhật state của nó rồi chờ từng listener xong mới đi tiếp (agent.ts:575), nên mỗi entry đã nằm trên đĩa trước khi vòng lặp bước sang việc kế tiếp.Đọc theo thứ tự:
- Đầu vào
prompt()không gặp lệnh extension, không có handlerinput, văn bản không phải skill hay template. Agent đang rảnh, mô hình có credential: gọi_startRun(). - Chuẩn bịPhiên mới chưa có assistant nào nên không kiểm tra compaction.
before_agent_startkhông đổi gì. Transcript chưa có messagesystemnào, nên bản vá chứa mọi phần prompt;declareToolChanges()gắn khai báo của bốn tool mặc định vào nó. - Hai message đầu
message_end(system)rồimessage_end(user). Entry ① chỉ nằm trong bộ nhớ cùngmodel_changevàthinking_level_changemàsdk.tsghi lúc tạo phiên; khi entry ② (messageuser) được ghi,SessionManagermới tạo tệp và viết header cùng mọi entry đang có (chương Session). - Yêu cầu thứ nhất
prepareRequestthay context bằng projection của session. Ba lớptransformContextchạy (sự kiệncontext, ẩn khai báo, prompt ép buộc),convertToLlm()hạ role,streamFngửi quaModelRuntime. TUI vẽ dần từmessage_update. - Lời gọi toolPhản hồi kết thúc với
stopReason: "toolUse"; entry ③ được ghi. HookbeforeToolCallpháttool_callcho extension (có thể chặn), toolreadchạy, hookafterToolCallpháttool_result(có thể sửa) và thu nhỏ ảnh nếu có. Entry ④ là kết quả tool. - Hết lượt 1
finishTurnphát boundaryturn_endcho extension; sự kiệnturn_endđổ các custom message bị hoãn (không có). - Lượt 2
prepareNextTurnWithContextkiểm tra ngưỡng compaction và tính bản vá system (không đổi, nên không có messagesystemmới). Yêu cầu thứ hai trả về văn bản vớistopReason: "stop"; entry ⑤. - Kết thúcKhông còn lời gọi tool, không có tin nhắn xếp hàng:
agent_endvớiwillRetry: false._handlePostAgentRun()trảfalse, boundaryagent_before_settlekhông xin chạy tiếp, khốifinallyphátagent_settled.
Ví dụ minh hoạ: tệp session sau kịch bản này có header và bảy entry, nối nhau thành một chuỗi qua parentId.
| Thứ tự | Entry | Nội dung chính | Ghi bởi |
|---|---|---|---|
| 0 | session (header) | version: 3, id, cwd | SessionManager.newSession() |
| 1 | model_change | provider, modelId ban đầu | sdk.ts |
| 2 | thinking_level_change | mức suy nghĩ ban đầu | sdk.ts |
| 3 ① | message, role system | sections đầy đủ, toolsAdded bốn tool | _handleAgentEvent |
| 4 ② | message, role user | văn bản prompt | _handleAgentEvent |
| 5 ③ | message, role assistant | khối toolCall gọi read, usage, stopReason: "toolUse" | _handleAgentEvent |
| 6 ④ | message, role toolResult | toolCallId, nội dung tệp | _handleAgentEvent |
| 7 ⑤ | message, role assistant | câu trả lời, stopReason: "stop" | _handleAgentEvent |
Dạng JSON của từng dòng có ở ví dụ JSONL trong chương Session.
Tự động thử lại khi provider lỗi#
Vấn đề: giữa chừng một lượt, provider trả "overloaded" (HTTP 529) hoặc kết nối bị đứt. Vòng lặp agent không ném lỗi; nó kết thúc lần chạy bằng một message assistant có stopReason: "error" và errorMessage. Không có retry, người dùng phải gõ "tiếp tục". Pi thử lại ở hai tầng:
- Tầng provider: SDK của provider tự thử lại một yêu cầu trước khi có phản hồi. AgentSession chỉ truyền tham số vào từ nhóm setting
retry.provider(timeoutMs,maxRetries,maxRetryDelayMs) quabuildRequestOptions(sdk.ts:340). - Tầng phiên: AgentSession xét phản hồi lỗi sau
agent_endvà chạy lại cả lượt. Phần này nằm trọn trongagent-session.ts.
/** * Check if an error is retryable (overloaded, rate limit, server errors). * Context overflow errors are NOT retryable (handled by compaction instead). */private _isRetryableError(message: AssistantMessage): boolean { // Context overflow is handled by compaction, not retry. if (isContextOverflow(message, (this._modelForMessage(message) ?? this.model)?.contextWindow ?? 0)) return false; return isRetryableAssistantError(message);}errorMessage: "overloaded", "rate limit", mã 429/500/502/503/504, lỗi mạng... (retry.ts:33), trừ các lỗi hết hạn mức hoặc hết tiền, loại lỗi không tự khỏi sau vài giây (retry.ts:266).private async _prepareRetry(message: AssistantMessage): Promise<boolean> { const settings = this.settingsManager.getRetrySettings(); if (!settings.enabled) { return false; } this._retryAttempt++; if (this._retryAttempt > settings.maxRetries) { // Preserve the completed attempt count so post-run handling can emit the final failure. this._retryAttempt--; return false; } const delayMs = retryDelayMs(settings, this._retryAttempt); this._emit({ type: "auto_retry_start", attempt: this._retryAttempt, maxAttempts: settings.maxRetries, delayMs, errorMessage: message.errorMessage || "Unknown error", }); // Keep the failed attempt in raw history while durably omitting it from model projection. this._omitRecoveryAttempt(message); // Wait with exponential backoff (abortable) this._retryAbortController = new AbortController(); try { await sleep(delayMs, this._retryAbortController.signal); } catch { // Aborted during sleep - emit end event so UI can clean up this._finishCancelledRetry(); return false; } finally { this._retryAbortController = undefined; } return true;baseDelayMs * 2^(attempt-1), chặn trên bởi maxAgentDelayMs (retry.ts:142). Mặc định maxRetries: 3, baseDelayMs: 2000, tức 2, 4, 8 giây (settings-defaults.ts:18). Thời gian chờ dùng một AbortController riêng, nên Esc huỷ được ngay trong lúc chờ.Dòng 3847 là phần đáng học nhất. Lần thử hỏng không bị xoá khỏi tệp, vì session chỉ ghi thêm. Thay vào đó AgentSession ghi một entry context_edit với replacement: null cho message lỗi và cho mọi kết quả tool đi kèm nó:
/** * Persistently omit a failed attempt and its tool results from model context. Results are * found in the projected context, so the pair stays together whether the attempt comes from * the current run or a resumed session. An unpaired result fails the next request (#11227). */private _omitRecoveryAttempt(message: AssistantMessage): void { const toolCallIds = new Set(message.content.flatMap((block) => (block.type === "toolCall" ? [block.id] : []))); // A context edit replaces the projected copy, so match it by source entry as well. const entryId = this._findPersistedMessageEntryId(message); const messages = this.agent.state.messages; const index = messages.findIndex( (m) => m === message || (entryId !== undefined && this._entryIdsByMessage.get(m) === entryId), ); const toolResults: AgentMessage[] = []; for (const m of index < 0 ? [] : messages.slice(index + 1)) { if (m.role === "assistant") break; if (m.role === "toolResult" && toolCallIds.has(m.toolCallId)) toolResults.push(m); } const targets = [message, ...toolResults]; const targetIds = targets.map((target) => this._findPersistedMessageEntryId(target)); const unresolvedProjectedTarget = targets.some( (target, index) => targetIds[index] === undefined && this.agent.state.messages.includes(target), ); if (unresolvedProjectedTarget) { throw new Error("Cannot persist recovery omission because a projected message has no source entry"); } for (const targetId of targetIds) { if (!targetId) continue; const editId = this.sessionManager.appendContextEdit(targetId, null); const entry = this.sessionManager.getEntry(editId); if (entry) this._emit({ type: "entry_appended", entry }); } this._refreshFinalizedContext();}Sau khi _prepareRetry() trả true, _handlePostAgentRun() lưu phản hồi hỏng vào _failedResponse rồi _startRun() gọi agent.continue(). Yêu cầu kế tiếp không còn thấy lần hỏng trong ngữ cảnh; nếu mô hình đang chọn là virtual model, router nhận nó qua failed với lý do "retry" (agent-session.ts:790). Khi một phản hồi assistant thành công đến, bộ đếm về 0 và auto_retry_end báo thành công (agent-session.ts:1177). Khi hết lượt thử, auto_retry_end mang success: false và lỗi cuối. UI biết trước có thử lại hay không nhờ trường willRetry trên agent_end, tính bằng cùng các điều kiện (agent-session.ts:1200).
Nơi compaction được kích hoạt#
Thuật toán compaction (nén ngữ cảnh): chọn điểm cắt, giữ khoảng 20.000 token gần nhất, viết bản tóm tắt; tất cả thuộc chương Compaction. Mục này chỉ trả lời câu hỏi: AgentSession gọi nó ở đâu và khi nào. Có năm điểm:
| Điểm | Hàm | Lý do | Chạy lại lượt? |
|---|---|---|---|
| Trước prompt mới | _prepareRun() gọi _checkCompaction(lastAssistant, false) | tràn hoặc vượt ngưỡng, kể cả khi lượt trước bị abort | không |
Sau agent_end | _handlePostAgentRun() gọi _checkCompaction(message) | overflow hoặc threshold | có, nếu phản hồi lỗi vì tràn |
| Giữa hai lượt của một lần chạy | _compactBeforeNextAssistantResponse() | threshold | không cần: lượt chưa bắt đầu |
| Trước yêu cầu của virtual model | prepareRequest | threshold, theo mô hình được route | không cần |
| Thủ công | compact() (/compact, RPC, ctx.compact()) | manual | không bao giờ |
* Automatic cases: * 1. Overflow with retry: a context-overflow error or recoverable length stop; * remove the failed assistant message, compact, and retry the turn once. * 2. Overflow without retry: a successful response exceeded the configured * context window; compact but preserve the completed response. * 3. Threshold without retry: valid or estimated context usage crossed the * configured threshold; compact without retrying the completed response.private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<boolean> { const settings = this.settingsManager.getCompactionSettings(this.model); if (!settings.enabled) return false; // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false; // Skip overflow check if the message came from a different model. // This handles the case where user switched from a smaller-context model (e.g. opus) // to a larger-context model (e.g. codex) - the overflow error from the old model // shouldn't trigger compaction for the new model. Under a virtual selection, the // physical model that produced the message supplies the limits. const messageModel = this._modelForMessage(assistantMessage); const sameModel = messageModel !== undefined; const contextWindow = (messageModel ?? this.model)?.contextWindow ?? 0; // Skip compaction checks if this assistant message is older than the latest // compaction boundary. This prevents a stale pre-compaction usage/error // from retriggering compaction on the first prompt after compaction. const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch()); const assistantIsFromBeforeCompaction = compactionEntry !== null && assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime(); if (assistantIsFromBeforeCompaction) { return false; }compaction.enabled, có ba phép lọc trước khi xét: phản hồi bị người dùng abort (trừ lần kiểm tra trước prompt); phản hồi của một mô hình khác (đổi từ mô hình cửa sổ nhỏ sang mô hình cửa sổ lớn thì lỗi tràn cũ không còn nghĩa); phản hồi cũ hơn compaction gần nhất (usage của nó phản ánh ngữ cảnh trước khi nén).if (contextOverflow || recoverableLength) { const willRetry = assistantMessage.stopReason !== "stop"; // Case 2: the response completed successfully. Compact, but do not retry because // agent.continue() cannot continue from a completed assistant response. if (!willRetry) { return await this._runAutoCompaction("overflow", false); } if (this._overflowRecoveryAttempted) { const errorMessage = contextOverflow ? "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model." : "Truncated response recovery failed after one compact-and-retry attempt."; this._emit({ type: "compaction_end", reason: "overflow", result: undefined, aborted: false, willRetry: false, errorMessage, }); await this._emitSessionCompactFailed({ reason: "overflow", errorMessage, aborted: false, willRetry: false, fromExtension: false, }); return false; } // Persistently omit the selected final attempt before post-run recovery compaction. this._overflowRecoveryAttempted = true; this._omitRecoveryAttempt(assistantMessage); const retry = await this._runAutoCompaction("overflow", willRetry); if (retry) this._failedResponse = assistantMessage; return retry;stopReason: "stop") mà vẫn vượt cửa sổ, chỉ nén, không thử lại: không thể continue() từ một phản hồi assistant đã xong. Nếu phản hồi lỗi vì tràn, lần thử hỏng bị loại khỏi ngữ cảnh bằng _omitRecoveryAttempt() như khi retry, rồi nén và chạy lại. _overflowRecoveryAttempted bảo đảm chỉ một lần; lần tràn thứ hai báo lỗi gợi ý giảm ngữ cảnh hoặc đổi mô hình lớn hơn.Trường hợp ngưỡng (agent-session.ts:3109) dùng số token ngữ cảnh từ usage của phản hồi; khi phản hồi lỗi hoặc usage bằng 0, nó ước lượng từ phản hồi hợp lệ gần nhất, và khi có context edit trên nhánh, nó ước lượng trên projection. Điều kiện cuối cùng đơn giản: contextTokens > contextWindow - reserveTokens (compaction.ts:267), với reserveTokens mặc định 16.384.
Điểm giữa hai lượt quan trọng với các lần chạy dài nhiều tool: không có nó, một chuỗi hai mươi lời gọi read có thể làm tràn ngữ cảnh trước khi lần chạy kết thúc.
private async _compactBeforeNextAssistantResponse(context: AgentContext): Promise<AgentContext> { const projection = this.sessionManager.buildSessionProjection(); // A virtual selection is checked in prepareRequest, against the model the request is routed to. const model = this.model; if (!model || isVirtualModel(model) || !this._exceedsCompactionThreshold(model, projection)) { return { ...context, messages: projection.messages }; } await this._runAutoCompaction("threshold", false); return { ...context, messages: this.sessionManager.buildSessionProjection().messages };}Mọi đường tự động đều đi qua _runAutoCompaction(): phát compaction_start, cho extension cơ hội huỷ hoặc tự cung cấp bản tóm tắt qua session_before_compact, ghi entry compaction, làm mới context, phát compaction_end (agent-session.ts:3157). Nó trả true (gọi agent.continue()) khi cần chạy lại lượt hoặc khi còn tin nhắn xếp hàng. Compaction thủ công thì khác ở một điểm: compact() gọi abort() trước tiên, nên lượt đang chạy bị dừng và không được tiếp tục (agent-session.ts:2828).
Đổi mô hình và mức suy nghĩ#
Đổi mô hình bằng /model hay xoay vòng bằng Ctrl+P là thao tác trên phiên, không phải trên settings. Mặc định nó chỉ có hiệu lực trong phiên hiện tại; tuỳ chọn persist mới ghi thêm vào settings toàn cục (agent-session.ts:328). Điều phiên cần là ghi lại lựa chọn vào transcript, để resume khôi phục đúng mô hình mà không đụng tới mặc định cho phiên mới.
async setModel(model: Model<any>, options: ModelMutationOptions = {}): Promise<void> { if (!(await this._modelRuntime.checkAuth(model.provider))) { throw new Error(`No API key for ${model.provider}/${model.id}`); } const previousModel = this.model; const thinkingLevel = this._getThinkingLevelForModelSwitch(model); this.agent.state.model = model; this.sessionManager.appendModelChange(model.provider, model.id); if (options.persist) { this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); this._addPersistedDefaultToNonEmptyScope(model); } // Apply thinking level for the new model. // Per-model thinking level overrides take priority over the global default. // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); await this._emitModelSelect(model, previousModel, "set");}agent.state.model. Nếu đổi khi đang chạy, nó có hiệu lực từ yêu cầu kế tiếp: cả prepareRequest lẫn prepareNextTurnWithContext đọc lại agent.state.model trước mỗi yêu cầu (agent-session.ts:928).Mức suy nghĩ khi đổi mô hình được chọn theo thứ tự: mức ghi kèm trong pattern --models (ví dụ sonnet:high), rồi mức riêng của mô hình trong modelThinkingLevels, rồi defaultThinkingLevel, rồi mức hiện tại (agent-session.ts:2733). cycleModel() xoay trong danh sách --models nếu có, nếu không thì trong mọi mô hình có credential (agent-session.ts:2583); cả hai nhánh cũng ghi model_change.
setThinkingLevel(level: ThinkingLevel, options: ModelMutationOptions = {}): void { const availableLevels = this.getAvailableThinkingLevels(); const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels); // Only persist if actually changing const previousLevel = this.agent.state.thinkingLevel; const isChanging = effectiveLevel !== previousLevel; this.agent.state.thinkingLevel = effectiveLevel; if (options.persist) { this.settingsManager.setDefaultThinkingLevel(level); } if (isChanging) { this.sessionManager.appendThinkingLevelChange(effectiveLevel); this._emit({ type: "thinking_level_changed", level: effectiveLevel }); void this._extensionRunner.emit({ type: "thinking_level_select", level: effectiveLevel, previousLevel, }); }}clampThinkingLevel của pi-ai). Entry thinking_level_change chỉ được ghi khi mức hiệu lực thay đổi, nên chuyển qua lại giữa hai mô hình không làm phình tệp. Khi persist, settings nhận mức người dùng yêu cầu, không phải mức đã kẹp.Lúc mở lại phiên, hai giá trị được đọc ngược từ nhánh: mức suy nghĩ lấy từ thinking_level_change cuối cùng, mô hình lấy từ model_change cuối cùng hoặc từ message assistant gần nhất (session-manager.ts:418). Virtual model là ngoại lệ: message assistant chỉ ghi tên mô hình vật lý đã trả lời, nên lựa chọn ảo chỉ tìm thấy trong model_change. _recordSelection() chạy trước mỗi lần chạy: khi lựa chọn hiện tại hoặc lựa chọn mà nhánh đang ghi là một virtual model và hai lựa chọn khác nhau, nó ghi thêm một model_change lên nhánh hiện tại, chẳng hạn sau khi /tree đưa lá sang một nhánh mà model_change cuối nằm ở nhánh kia (agent-session.ts:621).
Steer, follow-up, abort và tin nhắn tuỳ biến#
Người dùng không phải chờ agent làm xong mới gõ tiếp. Nhấn Enter khi agent đang chạy gửi một tin nhắn steer (chen vào lượt đang chạy); Alt+Enter (phím mặc định của app.message.followUp, keybindings.ts:67) gửi một follow-up (xếp hàng sau) (interactive-mode.ts:3335, 4462). Hai hàng đợi thật nằm trong Agent: vòng lặp agent lấy tin steer sau mỗi lượt, khi kết quả tool đã vào ngữ cảnh và trước yêu cầu kế tiếp; tin follow-up chỉ được lấy khi agent sắp dừng (agent-loop.ts:303, chi tiết ở chương pi-agent-core). Chế độ one-at-a-time (mặc định) lấy từng tin một, all lấy hết (agent.ts:165); AgentSession đọc chế độ từ setting steeringMode, followUpMode.
private async _queueSteer(text: string, images?: ImageContent[]): Promise<void> { this._steeringMessages.push(text); this._emitQueueUpdate(); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; if (images) { content.push(...images); } this.agent.steer({ role: "user", content, timestamp: Date.now(), });}/** * Internal: Queue a follow-up message (already expanded, no extension command check). */private async _queueFollowUp(text: string, images?: ImageContent[]): Promise<void> { this._followUpMessages.push(text); this._emitQueueUpdate(); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; if (images) { content.push(...images); } this.agent.followUp({ role: "user", content, timestamp: Date.now() });}_steeringMessages/_followUpMessages chỉ để hiển thị và phát queue_update. Bản sao được gỡ khi message user tương ứng bắt đầu, khớp theo nội dung văn bản (agent-session.ts:1120).Văn bản được mở rộng skill và template trước khi xếp hàng, nhưng một lệnh extension thì không xếp hàng được: steer() và followUp() ném lỗi nếu gặp nó (agent-session.ts:2332). Đi qua prompt() thì khác: lệnh extension được chạy ngay, cả khi agent đang bận.
Abort
async abort(): Promise<void> { if (this._isAgentRunActive) { this._agentRunAbortRequested = true; } this.abortRetry(); this.abortCompaction(); this.abortBranchSummary(); if (this._isBeforeSettle) this._abortDuringBeforeSettle = true; this.agent.abort(); await this.waitForIdle();}_agentRunAbortRequested chặn vòng lặp sau run trong _startRun() gọi agent.continue() thêm lần nào. Phản hồi đang stream kết thúc với stopReason: "aborted" và vẫn được ghi như mọi message khác.Ở interactive mode, Esc không chỉ gọi abort(): nó rút mọi tin đang xếp hàng bằng clearQueue() và đặt chúng lại vào trình soạn thảo, nối trước văn bản đang gõ, để người dùng sửa rồi gửi lại (interactive-mode.ts:4711).
Tin nhắn tuỳ biến của extension
Extension gửi message vào ngữ cảnh bằng pi.sendMessage(), đi tới sendCustomMessage(). Hàm này phải chọn giữa năm cách giao, tuỳ phiên đang chạy hay rảnh:
async sendCustomMessage<T = unknown>( message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },): Promise<void> { const appMessage = { role: "custom" as const, customType: message.customType, // Untyped extensions can pass null/missing content; normalize at ingestion. content: message.content ?? [], display: message.display, details: message.details, timestamp: Date.now(), } satisfies CustomMessage<T>; if (options?.deliverAs === "nextTurn") { this._pendingNextTurnMessages.push(appMessage); } else if (this.isStreaming && options?.triggerTurn !== false) { if (options?.deliverAs === "followUp") { this.agent.followUp(appMessage); } else { this.agent.steer(appMessage); } } else if (options?.triggerTurn) { if (this._isEmittingAgentSettled) { this._deferredSettledActions.push(async () => await this.sendCustomMessage(message, options)); return; } await this._startRun({ messageType: "custom", message: appMessage }); } else if (this.isStreaming) { // Appending now would put the message between an assistant tool call and its // result, which providers that validate message order reject on replay. Defer // to the end of the turn. Nothing is emitted yet: message events must not // describe messages the session tree does not contain. this._pendingCustomMessages.push(appMessage); } else { this._appendCustomMessage(appMessage); }}nextTurn: đính kèm vào prompt kế tiếp của người dùng. Đang chạy và được phép kích hoạt lượt: xếp vào hàng steer hoặc follow-up. Rảnh và triggerTurn: mở một lần chạy mới bắt đầu từ message này. Đang chạy nhưng không kích hoạt lượt: hoãn tới cuối lượt. Rảnh và không kích hoạt: ghi ngay.Trường hợp hoãn có lý do cụ thể. Nếu ghi ngay khi một lượt đang chạy, message custom có thể chen giữa message assistant chứa lời gọi tool và message toolResult của nó. Provider kiểm tra thứ tự message sẽ từ chối transcript đó khi phát lại. Vì vậy message nằm trong _pendingCustomMessages cho tới turn_end, thời điểm đầu tiên mà mọi kết quả tool của lượt đã được ghi (agent-session.ts:1190). Kết quả lệnh ! đi theo cùng nguyên tắc, như mục sau cho thấy. sendUserMessage() của extension thì đi qua prompt() với source: "extension" và mặc định không mở rộng template (agent-session.ts:2429).
Lệnh ! của người dùng#
Gõ !git status trong trình soạn thảo chạy lệnh ngay trên máy, không qua mô hình, rồi đưa kết quả vào ngữ cảnh để lượt sau mô hình biết. !!git status chạy y hệt nhưng không cho mô hình thấy. Interactive mode tách hai tiền tố:
// Handle bash command (! for normal, !! for excluded from context)if (text.startsWith("!")) { const isExcluded = text.startsWith("!!"); const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim(); if (command) { if (this.session.isBashRunning) { this.showWarning("A bash command is already running. Press Esc to cancel it first."); this.editor.setText(text); return; } this.editor.addToHistory?.(text); await this.handleBashCommand(command, isExcluded); this.isBashMode = false; this.updateEditorBorderColor(); return; }}! chạy. Trước khi chạy, extension nhận sự kiện user_bash và có thể tự trả kết quả hoặc cung cấp BashOperations khác, ví dụ để chạy qua SSH hay trong container (interactive-mode.ts:6960).async executeBash( command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean; id?: string; operations?: BashOperations },): Promise<BashResult> { const abortController = new AbortController(); this._bashAbortControllers.add(abortController); // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support) const prefix = this.settingsManager.getShellCommandPrefix(); const shellPath = this.settingsManager.getShellPath(); const resolvedCommand = prefix ? `${prefix}\n${command}` : command; try { const result = await executeBashWithOperations( resolvedCommand, this.sessionManager.getCwd(), options?.operations ?? createLocalBashOperations({ shellPath }), { onChunk: (delta) => { onChunk?.(delta); this._emit({ type: "bash_execution_update", id: options?.id, delta }); }, signal: abortController.signal, }, ); this.recordBashResult(command, result, options); return result; } finally { this._bashAbortControllers.delete(abortController); }}shellCommandPrefix (ví dụ shopt -s expand_aliases) được nối trước lệnh; mỗi mẩu output đi ra listener qua sự kiện bash_execution_update. RPC mode dùng cùng hàm này cho lệnh bash của nó.Việc chạy thật nằm trong bash-executor.ts. Nó phải chịu được lệnh in ra hàng trăm MB mà không giữ hết trong RAM:
// Start writing to temp file if exceeds threshold if (totalBytes > DEFAULT_MAX_BYTES) { ensureTempFile(); } if (tempFileStream) { tempFileStream.write(text); } // Keep rolling buffer outputChunks.push(text); outputBytes += text.length; while (outputBytes > maxOutputBytes && outputChunks.length > 1) { const removed = outputChunks.shift()!; outputBytes -= removed.length; }flushOutput();const fullOutput = outputChunks.join("");const truncationResult = truncateTail(fullOutput);if (truncationResult.truncated) { ensureTempFile();}tempFileStream?.end();const cancelled = options?.signal?.aborted ?? false;return { output: truncationResult.truncated ? truncationResult.content : fullOutput, exitCode: cancelled ? undefined : (exitCode ?? undefined), cancelled, truncated: truncationResult.truncated, fullOutputPath: tempFilePath,};\r), giữ trong một bộ đệm cuộn khoảng 100 KB (DEFAULT_MAX_BYTES * 2, dòng 56). Quá 50 KB thì toàn bộ output còn được ghi ra tệp tạm. Cuối cùng truncateTail() giữ phần đuôi trong giới hạn 2.000 dòng hoặc 50 KB, và fullOutputPath trỏ tới tệp đầy đủ.Kết quả trở thành một BashExecutionMessage. Điểm tinh tế: nếu agent đang chạy, message bị hoãn, cùng lý do với custom message ở mục trước:
recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void { const bashMessage: BashExecutionMessage = { role: "bashExecution", command, output: result.output, exitCode: result.exitCode, cancelled: result.cancelled, truncated: result.truncated, fullOutputPath: result.fullOutputPath, timestamp: Date.now(), excludeFromContext: options?.excludeFromContext, }; // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering if (this.isStreaming) { // Queue for later - will be flushed on agent_end this._pendingBashMessages.push(bashMessage); } else { this.sessionManager.appendMessage(bashMessage); this._refreshFinalizedContext(); }}finally của _startRun() (agent-session.ts:3985). Hàm này là public: một extension tự chạy lệnh trong user_bash vẫn gọi nó để kết quả vào session.Entry được ghi là một entry message thường với role bashExecution. Khi dựng yêu cầu, convertToLlm() biến nó thành message user với văn bản do bashExecutionToText() tạo: dòng "Ran `lệnh`", output trong khối code, mã thoát khác 0 nếu có, và đường dẫn output đầy đủ khi bị cắt (messages.ts:82). Đây không phải một kết quả tool: mô hình không gọi nó, nên không có toolCallId nào để ghép.
Các dịch vụ xung quanh: settings, auth, model, trust, đường dẫn#
AgentSession dựa vào các dịch vụ dưới đây. Mỗi dịch vụ đủ lớn để có tài liệu riêng; mục này chỉ nêu điều cần để hiểu vòng đời một prompt.
Settings: toàn cục và dự án
Có hai tệp: ~/.pi/agent/settings.json (toàn cục) và <cwd>/.pi/settings.json (dự án) (settings-manager.ts:186). Settings hiệu lực là hai tệp trộn sâu, tệp dự án thắng:
function isMergeableObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value);}function deepMergeObjects(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, unknown> { const result = { ...base }; for (const key of Object.keys(overrides)) { const overrideValue = overrides[key]; if (overrideValue === undefined) { continue; } const baseValue = base[key]; result[key] = isMergeableObject(baseValue) && isMergeableObject(overrideValue) ? deepMergeObjects(baseValue, overrideValue) : overrideValue; } return result;}/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */function deepMergeSettings(base: Settings, overrides: Settings): Settings { const merged = deepMergeObjects(base as Record<string, unknown>, overrides as Record<string, unknown>) as Settings; const defaultTools = mergeDefaultTools(base.defaultTools, overrides.defaultTools); return defaultTools === undefined ? merged : { ...merged, defaultTools };}compaction được trộn từng khoá, nên dự án có thể chỉ đặt compaction.reserveTokens mà vẫn giữ compaction.enabled toàn cục. Mảng bị thay nguyên, trừ defaultTools: một danh sách chỉ gồm +tên/-tên được nối vào danh sách kế thừa thay vì thay nó (settings-manager.ts:122).Ba chi tiết vận hành đáng biết. Thứ nhất, khi dự án chưa được tin cậy, tệp dự án không được đọc (settings-manager.ts:366) và mọi lệnh ghi vào nó bị từ chối. Thứ hai, các setter như setDefaultModelAndProvider() ghi vào phạm vi toàn cục; lúc ghi, tệp được đọc lại dưới file lock và chỉ những trường mà tiến trình này đã sửa được ghi đè, nên hai tiến trình pi đổi hai setting khác nhau không xoá thay đổi của nhau (settings-manager.ts:598). Thứ ba, lỗi ghi được ghi nhận vào danh sách lỗi chứ không ném ra giữa một lượt (settings-manager.ts:576). Kiểu Settings sinh từ schema TypeBox trong settings-schema.ts (settings-schema.ts:479), cùng schema mà settings.json tham chiếu qua $schema để editor gợi ý (docs/settings.md:5); giá trị mặc định nằm ở settings-defaults.ts.
Credential: auth.json và làm mới OAuth
AuthStorage cài đặt interface CredentialStore của pi-ai trên tệp ~/.pi/agent/auth.json, mỗi provider một credential: hoặc api_key, hoặc oauth với access, refresh, expires (auth/types.ts:17). Tệp được tạo với quyền 0600, thư mục cha 0700 (auth-storage.ts:37), mọi lần đọc ghi đi qua proper-lockfile. Khi đọc một api_key, giá trị được giải qua resolveConfigValue(): $NAME đọc biến môi trường, !lệnh chạy lệnh shell (auth-storage.ts:442).
Interface CredentialStore chỉ có một đường ghi, modify(provider, fn) (ngoài delete để đăng xuất): fn nhận credential hiện tại bên trong lock (auth/types.ts:78). Thiết kế đó phục vụ đúng một việc khó, làm mới OAuth khi nhiều tiến trình pi cùng chạy:
export async function refreshStoredOAuthCredential( credentials: CredentialStore, providerId: string, oauth: OAuthAuth, needsRefresh: (credential: OAuthCredential) => boolean, signal: AbortSignal,): Promise<OAuthCredential | undefined> { let post: Credential | undefined; const lockWait = new AbortController(); const cancelLockWait = () => lockWait.abort(signal.reason); signal.addEventListener("abort", cancelLockWait, { once: true }); if (signal.aborted) cancelLockWait(); try { post = await credentials.modify( providerId, async (current) => { signal.removeEventListener("abort", cancelLockWait); signal.throwIfAborted(); if (current?.type !== "oauth") return undefined; // logged out meanwhile if (!needsRefresh(current)) return undefined; // another process/request refreshed try { return await oauth.refresh(current, AbortSignal.timeout(DEFAULT_OAUTH_REFRESH_TIMEOUT_MS)); } catch (error) { throw new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error }); } }, { signal: lockWait.signal }, ); } catch (error) { if (error instanceof ModelsError) throw error; signal.throwIfAborted(); throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error }); } finally { signal.removeEventListener("abort", cancelLockWait); } return post?.type === "oauth" ? post : undefined;fn trả undefined và giữ nguyên. Lời gọi refresh bỏ qua tín hiệu huỷ của người gọi, chỉ bị giới hạn bởi timeout 15 giây, vì provider có thể đã xoay vòng refresh token: huỷ giữa chừng sẽ làm mất token hợp lệ duy nhất.Thứ tự nguồn credential: --api-key lúc chạy, rồi auth.json, rồi apiKey trong models.json, cuối cùng biến môi trường của provider (docs/models.md:23). Các bản cài cũ dùng oauth.json và apiKeys trong settings; migrateAuthToAuthJson() chuyển chúng sang auth.json một lần lúc khởi động (migrations.ts:21).
Mô hình: ModelRuntime, ModelRegistry và model-resolver
ModelRuntime là bộ sưu tập Models của pi-ai đã cấu hình cho coding agent (model-runtime.ts:173): provider dựng sẵn với danh mục mô hình có thể cập nhật từ pi.dev, provider khai báo trong models.json, provider và virtual model do extension đăng ký, credential từ AuthStorage. Mỗi lần streamSimple(), nó giải credential cho đúng yêu cầu đó rồi mới gọi provider (model-runtime.ts:659), nên một token OAuth hết hạn giữa phiên được làm mới trong suốt. Định tuyến virtual model, mô hình ảnh và classifier thuộc chương Pi 1.0. ModelRegistry chỉ là một mặt tiền đồng bộ bọc ModelRuntime cho extension (model-registry.ts:44).
--model nhận một pattern, không cần id chính xác. resolveCliModel() xét trên mọi mô hình, kể cả mô hình chưa có credential, để --api-key dùng được cho lần cài đặt đầu:
// Important: use *all* models here, not just models with pre-configured auth.// This allows "--api-key" to be used for first-time setup.const models = [...modelRuntime.getModels()];if (models.length === 0) { return { model: undefined, warning: undefined, error: "No models available. Check your installation or add models to models.json.", };}const provider = cliProvider ? models.find((m) => m.provider.toLowerCase() === cliProvider.toLowerCase())?.provider : undefined;if (cliProvider && !provider) { return { model: undefined, warning: undefined, error: `Unknown provider "${cliProvider}". Use --list-models to see available providers/models.`, };}const result = resolveModelReference( { text: cliModel, provider, thinkingSuffix: "strict", fuzzy: true }, models, // An inline key goes to whichever provider is selected, so stored credentials must not choose it inlineApiKey ? () => false : (providerId) => modelRuntime.hasConfiguredAuth(providerId),);resolveModelReference(): provider/id chỉ là gợi ý vì chính chuỗi đó có thể là id trên provider khác; khớp chính xác thắng khớp mờ; khớp mờ so chuỗi con trên id và tên, ưu tiên alias hơn bản có ngày (claude-sonnet-4-5 hơn claude-sonnet-4-5-20250929) (model-resolver.ts:286); hậu tố :high chỉ được tách khi cả chuỗi không khớp mô hình nào. Khớp ở nhiều provider có credential thì báo mơ hồ thay vì đoán; không khớp nhưng provider đã rõ thì dùng id tuỳ biến kèm cảnh báo.Khi không có --model và session không có gì để khôi phục, findInitialModel() chọn mô hình khởi đầu:
// 2. Try saved default from settings if auth is configured.if (defaultProvider && defaultModelId) { const found = modelRuntime.getModel(defaultProvider, defaultModelId); if (found && modelRuntime.hasConfiguredAuth(found.provider)) { model = found; const perModel = modelThinkingLevels?.[`${defaultProvider}/${defaultModelId}`]; if (perModel) { thinkingLevel = perModel; } else if (defaultThinkingLevel) { thinkingLevel = defaultThinkingLevel; } return { model, thinkingLevel, fallbackMessage: undefined }; }}// 3. Try first available model with valid API keyconst availableModels = [...modelRuntime.getAvailableSnapshot()];if (availableModels.length > 0) { // Try to find a default model from known providers for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) { const defaultId = defaultModelPerProvider[provider]; const match = availableModels.find((m) => m.provider === provider && m.id === defaultId); if (match) { return { model: match, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined }; } } // If no default found, use first available return { model: availableModels[0], thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };}--models khi không phải đang tiếp tục một phiên. Ở main.ts, --models được xử lý trước: nếu mô hình mặc định trong settings nằm trong phạm vi thì dùng nó, không thì dùng mô hình đầu tiên (main.ts:500).Trust: quyết định trước khi nạp tài nguyên dự án
Một thư mục lạ có thể chứa .pi/extensions/ là mã TypeScript chạy trong tiến trình pi. Vì vậy quyết định tin cậy dự án phải có trước khi những thứ đó được nạp (docs/how-pi-works.md:49). Chỉ những tài nguyên có thể thực thi hoặc đổi cấu hình mới đòi quyết định: .pi/settings.json, .pi/mcp.json, các thư mục extensions, skills, prompts, themes, SYSTEM.md, APPEND_SYSTEM.md, và .agents/skills ở thư mục hiện tại hoặc thư mục cha (trust-manager.ts:186). Thứ tự nạp trong ResourceLoader.reload() cho thấy cách làm:
async loadProjectTrustExtensions(): Promise<LoadExtensionsResult> { // Force untrusted project settings for the bootstrap pass. This keeps project-local // extensions/packages out while still loading user/global and temporary CLI extensions. this.settingsManager.setProjectTrusted(false); await this.settingsManager.reload(); return this.loadCurrentExtensionSet({ includeInlineFactories: true });}async reload(options?: ResourceLoaderReloadOptions): Promise<void> { resetTimings("extensions"); if (this.loaded) { clearExtensionCache(); } let preTrustExtensions: LoadExtensionsResult | undefined; if (options?.resolveProjectTrust) { preTrustExtensions = await this.loadProjectTrustExtensions(); const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions }); this.settingsManager.setProjectTrusted(projectTrusted); } // reload() preserves SettingsManager.projectTrusted and reloads settings for that trust state. await this.settingsManager.reload();project_trust.export async function resolveProjectTrusted(options: ResolveProjectTrustedOptions): Promise<boolean> { if (options.trustOverride !== undefined) { return options.trustOverride; } if (!hasTrustRequiringProjectResources(options.cwd)) { return true; } if (options.extensionsResult) { const { result, errors } = await emitProjectTrustEvent( options.extensionsResult, { type: "project_trust", cwd: options.cwd }, options.projectTrustContext, ); for (const error of errors) { options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`); } if (result) { const trusted = result.trusted === "yes"; if (result.remember === true) { options.trustStore.set(options.cwd, trusted); } return trusted; } } const decision = options.trustStore.get(options.cwd); if (decision !== null) { return decision; } switch (options.defaultProjectTrust ?? SETTINGS_DEFAULTS.defaultProjectTrust) { case "always": return true; case "never": return false; case "ask": break; }--approve/--no-approve thắng tất cả. Sau đó là extension, rồi quyết định đã lưu cho thư mục này hoặc thư mục cha gần nhất trong ~/.pi/agent/trust.json, rồi setting defaultProjectTrust. Với "ask", interactive mode hiện hộp chọn; các chế độ không có UI coi như không tin cậy.Trust không phải hộp cát: tool vẫn chạy với quyền hệ điều hành của tiến trình, và context file như AGENTS.md vẫn được nạp dù không tin cậy (docs/security.md:27). Tài liệu cũng nói rõ một kẽ hở: setting sessionDir của dự án được đọc lúc chọn session, trước khi trust được giải quyết (docs/security.md:31). Trong code, main.ts dùng một SettingsManager mở ở cwd khởi động để lấy sessionDir, rồi mới dựng runtime với trạng thái trust đã quyết định (main.ts:686).
Đường dẫn: config.ts
/** Get the agent config directory (e.g., ~/.pi/agent/) */export function getAgentDir(): string { const envDir = process.env[ENV_AGENT_DIR]; if (envDir) { return expandTildePath(envDir); } return join(homedir(), CONFIG_DIR_NAME, "agent");}piConfig trong package.json có thư mục và biến riêng. Quy tắc của repo: mọi đường dẫn tới tài nguyên của gói phải đi qua helper trong config.ts, không dùng __dirname, vì pi chạy được từ mã nguồn, từ gói npm và từ binary độc lập (AGENTS.md:22).| Đường dẫn | Nội dung |
|---|---|
~/.pi/agent/settings.json | settings toàn cục |
~/.pi/agent/auth.json | credential, quyền 0600 |
~/.pi/agent/models.json | provider và mô hình tuỳ biến |
~/.pi/agent/trust.json | quyết định trust theo thư mục |
~/.pi/agent/sessions/ | tệp session, một thư mục con cho mỗi cwd (chương Session) |
~/.pi/agent/prompts/, themes/, bin/ | prompt template, theme tuỳ biến, fd/rg do pi quản lý |
<cwd>/.pi/ | settings.json, mcp.json, extensions/, skills/, prompts/, themes/, SYSTEM.md, APPEND_SYSTEM.md của dự án (cần trust) |
Quyết định thiết kế#
Tệp session là bản gốc, mỗi yêu cầu được dựng lại từ nó
Cách đơn giản hơn là để Agent giữ lịch sử trong bộ nhớ và thỉnh thoảng lưu ra đĩa. Pi không làm vậy vì nhiều thứ ghi vào session mà không đi qua mảng message của Agent: extension ghi context_edit hay compaction ở boundary, retry loại một lần thử hỏng, /tree dời lá sang nhánh khác. Nếu yêu cầu kế tiếp đọc từ bộ nhớ, nó sẽ khác cái mà một lần resume dựng ra từ tệp. Dựng lại từ projection trước mọi yêu cầu (prepareRequest) loại bỏ khả năng lệch đó. Bài "The Session You Cannot Take With You" của Earendil nói thẳng lập trường này: nhật ký sự kiện cục bộ là bản chuẩn (earendil.com/posts/session-portability).
System prompt là bản vá trong transcript
Prompt cache của provider khớp theo tiền tố token chính xác: đổi system prompt hay danh sách tool ở đầu yêu cầu làm trượt cache cho toàn bộ phần sau (Earendil, "Prompt Caching In Agents"). Ghi thay đổi thành một message system mới ở cuối transcript giữ nguyên tiền tố cũ trên các mô hình hỗ trợ message system giữa hội thoại, và vì nó là một entry, thay đổi đó sống sót qua resume và qua điều hướng cây. Changelog 0.86.0 ghi đúng hai lý do này (CHANGELOG.md:609). Đổi lại, bản thân "system prompt hiện tại" không còn là một trường; muốn biết nó phải phát lại các message system, việc getCurrentSystemMessage() làm.
Không bao giờ chen message giữa lời gọi tool và kết quả
Custom message và kết quả lệnh ! đến lúc agent đang chạy đều bị hoãn (agent-session.ts:2385). Cái giá là người dùng thấy output của ! ở vùng chờ cho tới cuối lần chạy, và mô hình chỉ biết tới nó ở lần chạy sau. Cái được là transcript luôn hợp lệ khi phát lại cho bất kỳ provider nào, kể cả provider khắt khe về thứ tự.
Bỏ khỏi ngữ cảnh thay vì xoá
Retry và phục hồi sau tràn ngữ cảnh ghi context_edit thay vì sửa entry cũ. Lịch sử thô, UI, bản xuất HTML và thống kê token vẫn giữ lần thử hỏng (docs/session-format.md:141); getSessionStats() cộng usage trên mọi entry, kể cả phần đã bị compaction, để con số phản ánh đúng số tiền đã trả (agent-session.ts:4238). Context edit được thêm ở 0.87.0 (CHANGELOG.md:549).
Retry ở tầng phiên, tách khỏi retry của SDK
SDK của provider chỉ thử lại được trước khi có phản hồi. Một luồng đứt giữa chừng đã sinh ra nửa message assistant, có thể cả lời gọi tool; thử lại đúng nghĩa là bỏ nửa đó khỏi ngữ cảnh rồi chạy lại cả lượt. Chỉ tầng biết về session mới làm được việc này, nên nó nằm ở AgentSession chứ không ở pi-ai.
Extension trước, listener sau
Handler message_end của extension được phép thay cả message, chẳng hạn để che một khoá API lỡ xuất hiện trong nội dung. Phát cho extension trước và sửa đối tượng tại chỗ bảo đảm UI, agent state và tệp session cùng thấy một phiên bản (agent-session.ts:1287).
Thay cả phiên thay vì sửa phiên tại chỗ
Settings dự án, extension dự án, provider do extension đăng ký đều gắn với cwd. Khi /resume mở một session của dự án khác, sửa từng thứ tại chỗ dễ sót. AgentSessionRuntime chọn cách thô hơn nhưng chắc chắn: abort để lượt dở được ghi vào phiên cũ, huỷ, rồi dựng lại toàn bộ services cho cwd mới (agent-session-runtime.ts:168). Cái giá là extension phải hiểu rằng ctx cũ đã chết.
Cạm bẫy và trường hợp biên#
- Compaction thủ công chặn prompt. Trong lúc
/compactchạy,prompt()ném lỗi (agent-session.ts:2110); interactive mode tự xếp tin nhắn chờ thay vì gọi nó (interactive-mode.ts:3321). SDK và RPC phải tự xử lý. isIdlekhác!isStreaming. Compaction và tóm tắt nhánh đang chạy làm phiên không rảnh dù không có lần chạy nào (agent-session.ts:1478).@trong trình soạn thảo không đính kèm tệp. Chỉ đối số@filetrên dòng lệnh được đọc vào prompt; ở interactive mode,@pathchỉ là văn bản.sendUserMessage()của extension không mở rộng template trừ khi truyềnexpandPromptTemplates: true(agent-session.ts:2427).- Lỗi trong hook thành một phản hồi lỗi. Một ngoại lệ trong lúc chạy (credential hỏng, router của virtual model từ chối) không lan ra ngoài
prompt():Agenttạo một messageassistantvớistopReason: "error"và đi quamessage_endnhư mọi message (agent.ts:542). Nó được ghi vào session, và được thử lại nếu thông điệp lỗi khớp mẫu. - Phục hồi tràn ngữ cảnh chỉ một lần. Nếu ngữ cảnh sau compaction vẫn tràn, phiên dừng với thông báo lỗi thay vì lặp (agent-session.ts:3079).
- Mức suy nghĩ lưu vào settings là mức yêu cầu.
setThinkingLevel("xhigh", { persist: true })trên một mô hình chỉ cóhighghithinking_level_change: highvào session nhưngdefaultThinkingLevel: xhighvào settings. - Phiên cũ được vá metadata. Mở một session không có
thinking_level_changenào sẽ ghi thêm một entry như vậy ngay lúc tạo phiên (sdk.ts:440), nên tệp thay đổi dù người dùng chưa gõ gì. - ctx cũ của extension. Sau
ctx.newSession(),ctx.fork(),ctx.switchSession()hayctx.reload(), mọi lời gọi qua ctx cũ ném lỗi stale; phần việc sau khi thay phiên phải nằm trongwithSession.
Đọc code ở đâu#
Thứ tự đề xuất: dựng phiên trước, rồi lần theo một prompt, rồi các nhánh phụ.
Tóm tắt#
AgentSessionbọcAgentcủa pi-agent-core và dùng chung cho mọi chế độ chạy; nó nghe mọi sự kiện để ghi session và cài hook để mỗi yêu cầu được dựng lại từ projection của session.- Một prompt đi qua: lệnh extension, sự kiện
input, skill, prompt template, rồi hoặc vào hàng đợi (nếu đang chạy) hoặc mở một lần chạy: compaction trước prompt,before_agent_start, bản vá system prompt,agent.prompt(), vòng lặp sau run. - Chỉ
message_enddẫn tới ghi: bốn role của provider thành entrymessage,customthànhcustom_message; các entry khác (mô hình, mức suy nghĩ, compaction, context edit, nhãn) được ghi bởi lời gọi tương ứng. - System prompt và bộ tool sống trong transcript dưới dạng message
systemchứa bản vá, để giữ prompt cache và sống sót qua resume. - Retry ở tầng phiên: lỗi tạm thời được thử lại tối đa 3 lần với độ trễ 2, 4, 8 giây; lần hỏng bị loại khỏi ngữ cảnh bằng
context_edit, không bị xoá. - Compaction được kích hoạt ở năm điểm: trước prompt, sau run, giữa hai lượt, trước yêu cầu của virtual model, và thủ công.
- Custom message và output
!không bao giờ chen giữa lời gọi tool và kết quả: chúng được hoãn tới cuối lượt hoặc cuối lần chạy. - Xung quanh là settings hai tầng (dự án thắng, trộn sâu),
auth.jsoncó lock và làm mới OAuth kiểm tra hai lần, bộ giải pattern mô hình, và quyết định trust có trước khi nạp tài nguyên dự án.
8Session: cây JSONL, nhánh và context#
Vai trò#
Một session là bản ghi đầy đủ của một cuộc trò chuyện: mọi message, lời gọi tool và kết quả tool, mọi lần đổi mô hình (model), compaction (nén ngữ cảnh), nhãn, trạng thái extension. Pi lưu nó thành một tệp JSONL (mỗi dòng một đối tượng JSON) và tài liệu hoá định dạng đó trong packages/coding-agent/docs/session-format.md. Đó là một lựa chọn có chủ ý từ ngày đầu: Mario Zechner viết pi một phần vì muốn nhìn thấy và kiểm soát toàn bộ ngữ cảnh, với một định dạng session được tài liệu hoá để công cụ khác đọc được ("What I learned building an opinionated and minimal coding agent").
SessionManager trong packages/coding-agent/src/core/session-manager.ts sở hữu tệp đó. Chú thích của lớp tóm tắt toàn bộ mô hình:
/** * Manages conversation sessions as append-only trees stored in JSONL files. * * Each session entry has an id and parentId forming a tree structure. The "leaf" * pointer tracks the current position. Appending creates a child of the current leaf. * Branching moves the leaf to an earlier entry, allowing new branches without * modifying history. * * Use buildSessionContext() to get the resolved message list for the LLM, which * handles compaction summaries and follows the path from root to current leaf. */buildSessionProjection(); buildSessionContext() mà chú thích nhắc tới chỉ còn là lớp bọc mỏng bên trên nó.Ai gọi nó: AgentSession ghi entry qua các hàm append*() và dựng ngữ cảnh mỗi yêu cầu (chương AgentSession); main.ts chọn cách mở tệp theo cờ dòng lệnh; AgentSessionRuntime tạo tệp mới khi /new, /fork, /clone; bộ chọn cây của TUI đọc getTree() (chương pi-tui); bộ xuất HTML đọc mọi entry. Extension chỉ được cầm một bản đọc (ReadonlySessionManager, session-manager.ts:245); muốn ghi, chúng đi qua API của extension (chương Extensions).
Mô hình tư duy#
Ba ý cần giữ trong đầu:
- Chỉ ghi thêmEntry không bao giờ bị sửa hay xoá. Muốn "sửa" ngữ cảnh thì ghi thêm một entry mới (compaction, context edit, nhãn) mô tả thay đổi.
- Cây qua parentIdMỗi entry trỏ tới cha bằng
parentId. Quay lại một điểm cũ rồi gõ tiếp tạo một nhánh mới trong cùng tệp. - Con trỏ lá
leafIdlà vị trí hiện tại. Entry mới luôn là con của lá, và lá dời sang nó. Ngữ cảnh gửi cho mô hình là đường đi từ gốc tới lá.
header {type:"session", version:3, id, cwd} (dòng đầu, không thuộc cây)
[model_change] ─ [thinking_level_change] ─ [system] ─ [user U1] ─ [assistant A1]
│
┌───────────────────────────────────────────────┴──────┐
│ │
[user U2] ─ [assistant A2] [branch_summary] ─ [user U3] ─ [assistant A3]
nhánh cũ, vẫn nằm trong tệp ▲
lá (leafId)
nhánh đang hoạt động = model_change → … → A1 → branch_summary → U3 → A3
/tree, kèm tóm tắt nhánh vừa rời. Nhánh U2, A2 vẫn còn trong tệp nhưng không còn trong ngữ cảnh.Từ vựng của chương:
- entry
- Một dòng JSON sau header, có
type,id,parentId,timestamp. - gốc (root)
- Entry có
parentId: null. Thường chỉ có một, nhưngresetLeaf()cho phép tạo gốc thứ hai. - nhánh đang hoạt động (active branch)
- Đường đi từ gốc tới lá, trả về bởi
getBranch(). - context entries
- Nhánh đang hoạt động sau khi áp dụng compaction mới nhất (
buildContextEntries()). - projection
- Context entries đã đổi thành message mô hình sẽ thấy, sau khi áp dụng context edit, mỗi message giữ entry nguồn (
buildSessionProjection()).
Tệp session nằm ở đâu#
Mỗi thư mục làm việc (cwd) có một thư mục session riêng dưới ~/.pi/agent/sessions/. Tên thư mục mã hoá đường dẫn cwd:
/** * Compute the default session directory for a cwd. * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/. */function getDefaultSessionDirPath(cwd: string, agentDir: string = getDefaultAgentDir()): string { const resolvedCwd = resolvePath(cwd); const resolvedAgentDir = resolvePath(agentDir); const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; return join(resolvedAgentDir, "sessions", safePath);}export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string { const sessionDir = getDefaultSessionDirPath(cwd, agentDir); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } return sessionDir;}/, \, : bằng -, bọc trong --. Cwd /home/phuc/projects/my-research thành ~/.pi/agent/sessions/--home-phuc-projects-my-research--/.Tên tệp ghép thời điểm tạo và id phiên: 2026-10-22T05-04-11-123Z_<id>.jsonl, với : và . của timestamp ISO đổi thành - (session-manager.ts:1078). Id mặc định là một UUID phiên bản 7, loại UUID có phần đầu là dấu thời gian (session-manager.ts:264); tài liệu chỉ nói "UUID". Vì tên tệp bắt đầu bằng thời điểm tạo, sắp xếp tên tệp giảm dần cho ra phiên mới nhất trước, đúng thứ tự mà listSessionsFromDir() dùng để nạp dần danh sách (session-manager.ts:950). --session-id cho đặt id tuỳ ý, miễn chỉ gồm chữ, số, ., _, - và bắt đầu, kết thúc bằng chữ hoặc số (session-manager.ts:268).
Thư mục gốc có thể đổi, theo thứ tự ưu tiên: --session-dir, biến PI_CODING_AGENT_SESSION_DIR, setting sessionDir, rồi mặc định (main.ts:691). Khi thư mục là tuỳ biến, nhiều cwd có thể dùng chung một thư mục, nên các hàm tìm kiếm lọc thêm theo cwd trong header (session-manager.ts:1793).
--no-session (và --help, --list-models) dùng SessionManager.inMemory() (main.ts:364): cùng cấu trúc cây, cùng mọi hàm, chỉ là persist = false nên không gì chạm đĩa. Phiên kiểu này không thể resume sau khi pi thoát.
Header và các loại entry#
Dòng đầu tiên của tệp là header. Nó không có id cây hay parentId; mọi dòng sau đều kế thừa SessionEntryBase:
export const CURRENT_SESSION_VERSION = 3;export interface SessionHeader { type: "session"; version?: number; // v1 sessions don't have this id: string; timestamp: string; cwd: string; parentSession?: string;}export interface NewSessionOptions { id?: string; parentSession?: string;}export interface SessionEntryBase { type: string; id: string; parentId: string | null; timestamp: string;}cwd trong header quyết định phiên thuộc dự án nào khi resume. parentSession trỏ tới tệp gốc khi phiên được tạo bằng fork hoặc clone. Timestamp của entry là chuỗi ISO 8601, còn timestamp bên trong một message là số mili giây Unix (docs/session-format.md:47).Id của entry là 8 ký tự hex lấy từ randomUUID(), được kiểm tra trùng với các id đã có, và chỉ rơi về UUID đầy đủ nếu 100 lần thử đều trùng (session-manager.ts:276). Id ngắn giữ tệp gọn và đọc được bằng mắt; chúng chỉ cần duy nhất trong một tệp. Mười một loại entry theo sau.
export interface SessionMessageEntry extends SessionEntryBase { type: "message"; message: AgentMessage;}export interface ThinkingLevelChangeEntry extends SessionEntryBase { type: "thinking_level_change"; thinkingLevel: string;}export interface ModelChangeEntry extends SessionEntryBase { type: "model_change"; provider: string; modelId: string;}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;}message bọc một AgentMessage bất kỳ: system, user, assistant, toolResult, hoặc bashExecution của lệnh ! (chương AgentSession). usage ghi chi phí của những lời gọi mô hình không sinh message, như làm ấm prompt cache (kind: "cache_warm").export interface CompactionEntry<T = unknown> extends SessionEntryBase { type: "compaction"; summary: string; firstKeptEntryId: string; tokensBefore: number; /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ details?: T; /** Usage from the LLM call(s) that generated this summary, if available */ usage?: Usage; /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */ fromHook?: boolean; /** Complete prompt and tool state at this compaction boundary. */ systemMessage?: SystemMessage;}export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase { type: "branch_summary"; fromId: string; summary: string; /** Extension-specific data (not sent to LLM) */ details?: T; /** Usage from the LLM call that generated this summary, if available */ usage?: Usage; /** True if generated by an extension, false if pi-generated */ fromHook?: boolean;}firstKeptEntryId đánh dấu entry đầu tiên được giữ nguyên; mọi thứ trước nó trên nhánh được thay bằng summary. systemMessage là ảnh chụp toàn bộ system prompt và bộ tool tại điểm nén. branch_summary mang tóm tắt của nhánh vừa rời, với fromId là lá cũ. Trường fromHook là tên cũ, nghĩa là "do extension tạo"./** * Custom entry for extensions to store extension-specific data in the session. * Use customType to identify your extension's entries. * * Purpose: Persist extension state across session reloads. On reload, extensions can * scan entries for their customType and reconstruct internal state. * * Does NOT participate in LLM context (ignored by buildSessionContext). * For injecting content into context, see CustomMessageEntry. */export interface CustomEntry<T = unknown> extends SessionEntryBase { type: "custom"; customType: string; data?: T;}/** Label entry for user-defined bookmarks/markers on entries. */export interface LabelEntry extends SessionEntryBase { type: "label"; targetId: string; label: string | undefined;}/** Session metadata entry (e.g., user-defined display name). */export interface SessionInfoEntry extends SessionEntryBase { type: "session_info"; name?: string;}custom là chỗ extension lưu trạng thái của nó và không bao giờ vào ngữ cảnh. Pi cũng dùng nó cho trạng thái router của virtual model (customType: "pi.virtual-model-state"). label với label: undefined xoá nhãn./** * Custom message entry for extensions to inject messages into LLM context. * Use customType to identify your extension's entries. * * Unlike CustomEntry, this DOES participate in LLM context. * The content is converted to a user message in buildSessionContext(). * Use details for extension-specific metadata (not sent to LLM). * * display controls TUI rendering: * - false: hidden entirely * - true: rendered with distinct styling (different from user messages) */export interface CustomMessageEntry<T = unknown> extends SessionEntryBase { type: "custom_message"; customType: string; content: string | (TextContent | ImageContent)[]; details?: T; display: boolean;}/** Content that an append-only context edit may replace without changing message metadata. */export type ContextEditableContent = | UserMessage["content"] | AssistantMessage["content"] | ToolResultMessage["content"] | CustomMessage["content"];/** Append-only change to one earlier entry's contribution to model context. */export interface ContextEditEntry extends SessionEntryBase { type: "context_edit"; targetId: string; /** Null omits the target from model context. A value replaces only its content. */ replacement: { content: ContextEditableContent } | null;}custom, custom_message có vào ngữ cảnh. Chú thích nói nội dung "được đổi thành message user trong buildSessionContext()"; thực tế ở v1.4.0 buildSessionProjection() sinh một message role custom, và việc hạ về user xảy ra trong convertToLlm() lúc gửi. context_edit thay nội dung của một entry trước đó, hoặc bỏ nó (replacement: null), chỉ trong ngữ cảnh./** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */export type SessionEntry = | SessionMessageEntry | ThinkingLevelChangeEntry | ModelChangeEntry | UsageEntry | CompactionEntry | BranchSummaryEntry | CustomEntry | CustomMessageEntry | ContextEditEntry | LabelEntry | SessionInfoEntry;/** Raw file entry (includes header) */export type FileEntry = SessionHeader | SessionEntry;Bảng dưới gom lại: loại nào vào ngữ cảnh, và ai ghi nó.
type | Vào ngữ cảnh? | Ghi bởi |
|---|---|---|
message | Có, chính message đó (bashExecution với !! bị bỏ khi gửi) | appendMessage() |
thinking_level_change | Không; chỉ đặt mức suy nghĩ khi resume | appendThinkingLevelChange() |
model_change | Không; chỉ chọn mô hình khi resume | appendModelChange() |
usage | Không; cộng vào thống kê | appendUsage() |
compaction | Có: message system ảnh chụp, rồi compactionSummary | appendCompaction() |
branch_summary | Có: branchSummary (nếu tóm tắt khác rỗng) | branchWithSummary() |
custom | Không | appendCustomEntry() |
custom_message | Có: message custom | appendCustomMessageEntry() |
context_edit | Không tự sinh message; sửa hoặc bỏ entry đích | appendContextEdit() |
label | Không | appendLabelChange() |
session_info | Không | appendSessionInfo() |
Ví dụ minh hoạ một tệp JSONL#
Ví dụ minh hoạ: tệp của kịch bản ở chương AgentSession (hỏi version trong package.json, mô hình gọi read một lần), sau đó người dùng đặt tên phiên bằng /name và gắn nhãn cho câu trả lời. Id, thời điểm và nội dung là bịa; tên trường đúng với các type ở trên. Dấu … đánh dấu chỗ rút gọn (nội dung system prompt, schema tool, đối tượng usage), không phải cú pháp JSON.
{"type":"session","version":3,"id":"019a0b2c-7d3e-7f41-9c2a-5b6e8d1f0a37","timestamp":"2026-10-22T05:04:11.123Z","cwd":"/home/phuc/projects/demo"}
{"type":"model_change","id":"a1b2c3d4","parentId":null,"timestamp":"2026-10-22T05:04:11.130Z","provider":"anthropic","modelId":"claude-sonnet-4-5"}
{"type":"thinking_level_change","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"2026-10-22T05:04:11.131Z","thinkingLevel":"medium"}
{"type":"message","id":"c3d4e5f6","parentId":"b2c3d4e5","timestamp":"2026-10-22T05:04:20.005Z","message":{"role":"system","content":"","sections":{"preamble":"You are an expert coding assistant operating inside pi, …","tools":"<tools>\n- read: …","rules":"<rules>…","docs":"<docs>…","cwd":"<cwd>\n/home/phuc/projects/demo\n</cwd>"},"toolsAdded":[{"name":"read","description":"…","parameters":{…}},{"name":"bash",…},{"name":"edit",…},{"name":"write",…}],"timestamp":1792645460003}}
{"type":"message","id":"d4e5f607","parentId":"c3d4e5f6","timestamp":"2026-10-22T05:04:20.006Z","message":{"role":"user","content":[{"type":"text","text":"package.json khai báo version nào?"}],"timestamp":1792645460001}}
{"type":"message","id":"e5f60718","parentId":"d4e5f607","timestamp":"2026-10-22T05:04:23.410Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01","name":"read","arguments":{"path":"package.json"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{…},"stopReason":"toolUse","thinkingLevel":"medium","timestamp":1792645460010}}
{"type":"message","id":"f6071829","parentId":"e5f60718","timestamp":"2026-10-22T05:04:23.452Z","message":{"role":"toolResult","toolCallId":"toolu_01","toolName":"read","content":[{"type":"text","text":"{\n \"name\": \"demo\",\n \"version\": \"1.2.3\"\n}"}],"isError":false,"timestamp":1792645463451}}
{"type":"message","id":"0718293a","parentId":"f6071829","timestamp":"2026-10-22T05:04:25.977Z","message":{"role":"assistant","content":[{"type":"text","text":"Version là 1.2.3."}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{…},"stopReason":"stop","thinkingLevel":"medium","timestamp":1792645463460}}
{"type":"session_info","id":"18293a4b","parentId":"0718293a","timestamp":"2026-10-22T05:05:02.000Z","name":"Kiểm tra version"}
{"type":"label","id":"293a4b5c","parentId":"18293a4b","timestamp":"2026-10-22T05:05:10.000Z","targetId":"0718293a","label":"đã trả lời"}
parentId nối mọi dòng thành một chuỗi, kể cả session_info và label: chúng cũng là nút của cây.Vài điều đọc được từ ví dụ. Message system có timestamp lớn hơn message user dù đứng trước: _prepareRun() tạo message user trước, rồi mới tạo bản vá system và chèn nó lên đầu mảng (chương AgentSession, bước 2). Các phần prompt khác preamble được bọc trong thẻ cùng tên, như <cwd>…</cwd> (system-prompt.ts:188). Mỗi message assistant mang thinkingLevel được yêu cầu cho chính phản hồi đó, do vòng lặp agent gắn vào (agent-loop.ts:419).
Ghi thêm và nạp lại#
Mọi hàm append*() có cùng hình dạng: tạo entry với parentId là lá hiện tại, rồi giao cho _appendEntry():
private _appendEntry(entry: SessionEntry): void { this.fileEntries.push(entry); this.byId.set(entry.id, entry); this.leafId = entry.id; this._persist(entry);}/** Append a message as child of current leaf, then advance leaf. Returns entry id. * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly. * Reason: we want these to be top-level entries in the session, not message session entries, * so it is easier to find them. * These need to be appended via appendCompaction() and appendBranchSummary() methods. */appendMessage(message: Message | CustomMessage | BashExecutionMessage): string { const entry: SessionMessageEntry = { type: "message", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), message, }; this._appendEntry(entry); return entry.id;}appendMessage() cố ý loại hai role tóm tắt: chúng phải là entry cấp cao (compaction, branch_summary) để dễ tìm, và message tương ứng được sinh lại mỗi lần dựng ngữ cảnh.Ghi xuống đĩa là một lệnh appendFileSync một dòng. Ngoại lệ là lần đầu:
/** * A new session file is created only once the session contains a user or assistant message. * Setup entries alone (model, thinking level, system prompt) stay in memory so opening and * closing pi without chatting leaves no file behind. Starting at the user message (not the * first assistant reply) keeps the prompt on disk if the first turn never completes (#10000). */private _hasConversation(): boolean { return this.fileEntries.some( (e) => e.type === "message" && (e.message.role === "user" || e.message.role === "assistant"), );}_persist(entry: SessionEntry): void { if (!this.persist || !this.sessionFile) return; if (!this.flushed) { if (!this._hasConversation()) return; const fd = openSync(this.sessionFile, "wx"); try { for (const e of this.fileEntries) { writeFileSync(fd, `${JSON.stringify(e)}\n`); } } finally { closeSync(fd); } this.flushed = true; } else { appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); }}user đầu tiên đến, tệp được mở với cờ "wx" (thất bại nếu tệp đã tồn tại) và mọi entry đang có được viết một lượt. Chọn mốc là message user chứ không phải phản hồi đầu tiên, để prompt không bị mất nếu lượt đầu không bao giờ xong: đó là lỗi #10000, sửa ở 0.99.0 (CHANGELOG.md:482).Nạp lại một tệp đi theo chiều ngược. loadEntriesFromFile() đọc theo khối 1 MB và tách dòng dần, thay vì đọc cả tệp thành một chuỗi lớn rồi mới tách:
while (true) { const bytesRead = readSync(fd, buffer, 0, buffer.length, null); if (bytesRead === 0) break; pending += decoder.write(buffer.subarray(0, bytesRead)); let lineStart = 0; let newlineIndex = pending.indexOf("\n", lineStart); while (newlineIndex !== -1) { const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex)); if (entry) entries.push(entry); lineStart = newlineIndex + 1; newlineIndex = pending.indexOf("\n", lineStart); } pending = pending.slice(lineStart); } pending += decoder.end(); const finalEntry = parseSessionEntryLine(pending); if (finalEntry) entries.push(finalEntry);} finally { closeSync(fd);}// Validate session header before repairing the file.if (entries.length === 0) return entries;const header = entries[0];if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") { return [];}if (pending) appendFileSync(resolvedFilePath, "\n");return entries;StringDecoder giữ các byte UTF-8 bị cắt ngang giữa hai khối. Dòng không parse được bị bỏ qua lặng lẽ. Nếu dòng đầu không phải header hợp lệ, hàm trả mảng rỗng. Nếu tệp kết thúc giữa chừng một dòng (pi bị giết khi đang ghi), nó thêm một ký tự xuống dòng để lần ghi sau bắt đầu ở dòng mới.Hàm gọi nó, _setSessionFile(), phân biệt ba trường hợp: tệp không tồn tại thì tạo phiên mới nhưng giữ đường dẫn được chỉ định (cho --session đường/dẫn.jsonl); tệp rỗng thì khởi tạo header; tệp có nội dung mà không phải session thì ném lỗi thay vì ghi đè (session-manager.ts:1029). Sau đó chỉ mục trong bộ nhớ được dựng lại:
private _buildIndex(): void { this.byId.clear(); this.labelsById.clear(); this.labelTimestampsById.clear(); this.leafId = null; for (const entry of this.fileEntries) { if (entry.type === "session") continue; this.byId.set(entry.id, entry); this.leafId = entry.id; if (entry.type === "label") { if (entry.label) { this.labelsById.set(entry.targetId, entry.label); this.labelTimestampsById.set(entry.targetId, entry.timestamp); } else { this.labelsById.delete(entry.targetId); this.labelTimestampsById.delete(entry.targetId); } } }}label theo thứ tự; nhãn rỗng xoá nhãn trước.Phiên bản và migration#
Định dạng đã đổi hai lần (docs/session-format.md:22). Phiên bản 1 là một chuỗi tuyến tính không có id. Phiên bản 2 (pi 0.31.0, tháng 1 năm 2026) thêm id/parentId để rẽ nhánh tại chỗ (CHANGELOG.md:5150). Phiên bản 3 đổi role hookMessage thành custom khi hook được hợp nhất thành extension. Header ghi phiên bản; thiếu trường này nghĩa là v1.
/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */function migrateV1ToV2(entries: FileEntry[]): void { const ids = new Set<string>(); let prevId: string | null = null; for (const entry of entries) { if (entry.type === "session") { entry.version = 2; continue; } entry.id = generateId(ids); entry.parentId = prevId; prevId = entry.id; // Convert firstKeptEntryIndex to firstKeptEntryId for compaction if (entry.type === "compaction") { const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number }; if (typeof comp.firstKeptEntryIndex === "number") { const targetEntry = entries[comp.firstKeptEntryIndex]; if (targetEntry && targetEntry.type !== "session") { comp.firstKeptEntryId = targetEntry.id; } delete comp.firstKeptEntryIndex; } } }}firstKeptEntryIndex (chỉ số dòng); migration đổi nó thành firstKeptEntryId trỏ vào entry ở vị trí đó./** * Run all necessary migrations to bring entries to current version. * Mutates entries in place. Returns true if any migration was applied. */function migrateToCurrentVersion(entries: FileEntry[]): boolean { const header = entries.find((e) => e.type === "session") as SessionHeader | undefined; const version = header?.version ?? 1; if (version >= CURRENT_SESSION_VERSION) return false; if (version < 2) migrateV1ToV2(entries); if (version < 3) migrateV2ToV3(entries); return true;}Migration chạy trong _loadEntries() mỗi khi một tệp được nạp, sửa mảng trong bộ nhớ, rồi viết lại toàn bộ tệp (session-manager.ts:1085). Ngoài việc khởi tạo một tệp rỗng và viết một tệp mới khi fork, đây là chỗ duy nhất một tệp session đã có nội dung bị viết lại thay vì ghi thêm, và nó chỉ xảy ra một lần cho mỗi tệp cũ.
src/migrations.ts là chuyện khác: các migration một lần lúc khởi động cho cấu hình và vị trí tệp, chạy trước khi chọn session (main.ts:668). Phần liên quan tới session là migrateSessionsFromAgentRoot(): bản 0.30.0 lỡ lưu session thẳng vào ~/.pi/agent/, nên hàm này đọc cwd từ header của từng tệp và chuyển nó về đúng thư mục sessions/--cwd--/ (migrations.ts:75). Các migration khác chuyển credential sang auth.json, commands/ sang prompts/, tools/ sang bin/ (migrations.ts:300).
Dựng lại context từ nhánh đang hoạt động#
Mỗi yêu cầu tới mô hình bắt đầu bằng một câu hỏi: từ cây entry này, mô hình phải thấy danh sách message nào? buildSessionProjection() trả lời bằng bốn bước. Hàm được gọi trước mỗi yêu cầu (chương AgentSession), và chi phí của nó chỉ là vài lần duyệt tuyến tính: sao chép danh sách entry, đi ngược từ lá về gốc, rồi duyệt đường đi đó.
Bước 1: đi từ lá về gốc
function buildSessionPath( entries: SessionEntry[], leafId?: string | null, byId?: Map<string, SessionEntry>,): SessionEntry[] { const index = buildEntryIndex(entries, byId); let leaf: SessionEntry | undefined; if (leafId === null) { return []; } if (leafId) { leaf = index.get(leafId); } leaf ??= entries[entries.length - 1]; if (!leaf) { return []; } const path: SessionEntry[] = []; let current: SessionEntry | undefined = leaf; while (current) { path.push(current); current = current.parentId ? index.get(current.parentId) : undefined; } path.reverse(); return path;}leafId === null (sau resetLeaf()) cho đường rỗng; không truyền leafId thì lấy entry cuối mảng. Nhánh khác của cây không bao giờ được chạm tới.Trên đường đi đó, getSessionContextSettings() đọc mức suy nghĩ từ thinking_level_change cuối cùng và mô hình từ model_change hay message assistant cuối cùng (session-manager.ts:418). Hai giá trị này chỉ dùng khi resume; chúng không thành message.
Bước 2: áp dụng compaction mới nhất
export function buildContextEntries( entries: SessionEntry[], leafId?: string | null, byId?: Map<string, SessionEntry>,): SessionEntry[] { const path = buildSessionPath(entries, leafId, byId); let compaction: CompactionEntry | null = null; for (const entry of path) { if (entry.type === "compaction") { compaction = entry; } } if (!compaction) { return path; } const compactionIdx = path.findIndex((entry) => entry.id === compaction.id); if (compactionIdx < 0) { return path; } const contextEntries: SessionEntry[] = [compaction]; let foundFirstKept = false; for (let i = 0; i < compactionIdx; i++) { const entry = path[i]; if (entry.id === compaction.firstKeptEntryId) { foundFirstKept = true; } if (foundFirstKept && !(entry.type === "message" && entry.message.role === "system")) { contextEntries.push(entry); } } contextEntries.push(...path.slice(compactionIdx + 1)); return contextEntries;}firstKeptEntryId tới trước compaction (bỏ message system), rồi mọi entry sau compaction. Message system trong phần giữ lại bị bỏ vì ảnh chụp systemMessage của compaction đã chứa trạng thái prompt đầy đủ tại điểm đó. Entry không phải message (nhãn, model_change...) vẫn được giữ để interactive mode vẽ được.Bước 3: đổi entry thành message
/** * Project one selected session entry into LLM/runtime messages. * Plain custom entries are display/state entries and do not participate in context. */export function sessionEntryToContextMessages(entry: SessionEntry): AgentMessage[] { if (entry.type === "message") { const message = entry.message; // Session files are parsed without validation; old versions, forks, or // hand-edited files can contain messages with null/missing content. if (message.role === "system" && message.content == null) return [{ ...message, content: "" }]; if ( (message.role === "user" || message.role === "assistant" || message.role === "toolResult") && message.content == null ) { return [{ ...message, content: [] }]; } return [message]; } if (entry.type === "custom_message") { return [ createCustomMessage(entry.customType, entry.content ?? [], entry.display, entry.details, entry.timestamp), ]; } if (entry.type === "branch_summary" && entry.summary) { return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)]; } if (entry.type === "compaction") { const summary = createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); return entry.systemMessage ? [entry.systemMessage, summary] : [summary]; } return [];}null (từ bản cũ, từ fork, từ tệp sửa tay) được chuẩn hoá thành rỗng thay vì làm vỡ yêu cầu. Compaction cho ra hai message, ảnh chụp system rồi tóm tắt; entry compaction cũ không có ảnh chụp thì chỉ cho ra tóm tắt.Bước 4: áp dụng context edit, giữ nguồn gốc
/** Build provenance-preserving, compaction-aware model context. */export function buildSessionProjection( entries: SessionEntry[], leafId?: string | null, byId?: Map<string, SessionEntry>,): SessionProjection { const path = buildSessionPath(entries, leafId, byId); const { thinkingLevel, model } = getSessionContextSettings(path); const contextEntries = buildContextEntries(entries, leafId, byId); const edits = new Map<string, ContextEditEntry>(); for (const entry of contextEntries) { if (entry.type === "context_edit") edits.set(entry.targetId, entry); } const projectedEntries = contextEntries.map( (sourceEntry, index): ProjectedSessionEntry => ({ sourceEntry, // buildContextEntries() may retain an older compaction entry because its // raw ID lies inside the newest retained range. Only the newest compaction // at index zero contributes a checkpoint and summary. messages: sourceEntry.type === "compaction" && index > 0 ? [] : projectContextEntry(sourceEntry, edits.get(sourceEntry.id)), }), ); return { entries: projectedEntries, messages: projectedEntries.flatMap((entry) => entry.messages), thinkingLevel, model, };}edits là một Map ghi đè theo thứ tự nhánh, nên với cùng một đích, edit sau cùng thắng. Edit chỉ có hiệu lực khi nó nằm trên nhánh đang hoạt động: điều hướng về trước nó thì đích hiện lại nguyên bản (docs/session-format.md:147). Một compaction cũ hơn có thể lọt vào phần được giữ; nó không đóng góp message nào.Việc thay nội dung nằm trong projectContextEntry(): replacement: null cho mảng rỗng; nội dung mới thay content nhưng giữ role và metadata, và chuỗi được bọc thành một khối text cho assistant, toolResult vì hai role này đòi mảng (session-manager.ts:519). Mỗi ProjectedSessionEntry giữ sourceEntry: AgentSession dùng nó để biết message nào ứng với entry nào khi cần ghi một context edit hay báo id entry cho extension. buildSessionContext() chỉ bỏ phần nguồn gốc đó đi (session-manager.ts:575).
Ví dụ: từ cây entry đến danh sách message#
Ví dụ minh hoạ, đủ mọi loại entry có ảnh hưởng tới ngữ cảnh. Người dùng nhờ viết hàm parseDate, rồi nhờ thêm test bằng vitest; nhánh đó thất bại vì vitest chưa được cài. Họ dùng /tree quay về sau câu trả lời A2, chọn tóm tắt nhánh vừa rời, và yêu cầu viết test bằng node:test. Một extension todo gửi một custom message cuối lượt. Ngữ cảnh vượt ngưỡng nên compaction tự động chạy, giữ lại từ U3. Ở prompt sau, provider trả "overloaded" một lần, retry thành công.
M0 model_change anthropic / claude-sonnet-4-5
└─ T0 thinking_level_change medium
└─ S0 message (system) mọi phần prompt, toolsAdded: read, bash, edit, write
└─ U1 message (user) "Viết hàm parseDate trong src/date.ts"
└─ A1 message (assistant) toolCall write
└─ R1 message (toolResult)
└─ A2 message (assistant) "Đã viết xong parseDate."
├─ U2 message (user) "Thêm test bằng vitest" ┐
│ └─ A3 ─ R2 ─ A4 (gọi bash, vitest chưa cài, thất bại) ┘ nhánh bị bỏ
└─ BS branch_summary fromId = A4
└─ U3 message (user) "Viết test bằng node:test"
└─ A5 message (assistant) "Đã thêm test/date.test.ts"
└─ CM custom_message customType "todo", display true
└─ C compaction firstKeptEntryId = U3, có systemMessage
└─ U4 message (user) "Chạy lại toàn bộ test"
└─ AE message (assistant) stopReason "error", "overloaded"
└─ CE context_edit targetId = AE, replacement null
└─ A6 message (assistant) "12 test đều qua." ◄ lá
bash, kết quả tool, assistant báo lỗi.Lần theo bốn bước với lá là A6:
- Đường điA6 → CE → AE → U4 → C → CM → A5 → U3 → BS → A2 → R1 → A1 → U1 → S0 → T0 → M0, đảo lại. U2, A3, R2, A4 không nằm trên đường nên không bao giờ được xét.
- Thiết lậpMức suy nghĩ
mediumtừ T0; mô hình từ message assistant cuối trên đường, A6. - CompactionC là compaction duy nhất. Context entries = [C, U3, A5, CM] + [U4, AE, CE, A6]. M0 tới BS đứng trước
firstKeptEntryIdnên bị thay bằng tóm tắt của C, kể cả bản tóm tắt nhánh BS. - Context editCE nhắm AE với
replacement: null, nên AE không cho ra message nào; bản thân CE cũng không.
| # | Entry nguồn | Message trong projection | Gửi provider (sau convertToLlm) |
|---|---|---|---|
| 1 | C | system: ảnh chụp prompt và tool tại lúc nén | system |
| 2 | C | compactionSummary | user: "The conversation history before this point was compacted into the following summary:" + <summary>…</summary> |
| 3 | U3 | user | user |
| 4 | A5 | assistant | assistant |
| 5 | CM | custom (customType: "todo") | user với nội dung của CM; details không được gửi |
| 6 | U4 | user | user |
| 7 | A6 | assistant | assistant |
Prompt kế tiếp của người dùng sẽ thành entry con của A6 và message thứ 8. Bây giờ đổi lá: nếu người dùng dùng /tree chọn A4, đường đi là M0 … A2 → U2 → A3 → R2 → A4, không có compaction và context edit nào trên đó, nên projection là S0, U1, A1, R1, A2, U2, A3, R2, A4 nguyên vẹn. Cùng một tệp, hai lá, hai ngữ cảnh hoàn toàn độc lập; compaction C và edit CE chỉ thuộc về nhánh của chúng.
Rẽ nhánh trong cùng tệp: /tree#
Pi có ba cách quay lại một điểm cũ (docs/sessions.md:20): /tree đi trong cùng tệp, /fork và /clone tạo tệp mới. Armin Ronacher mô tả cây session như chỗ cho các "side quest": rẽ sang một việc phụ rồi quay lại nhánh chính mà không làm bẩn ngữ cảnh của nó ("Pi: The Minimal Agent Within OpenClaw"). Ở tầng tệp, rẽ nhánh chỉ là dời con trỏ:
/** * Start a new branch from an earlier entry. * Moves the leaf pointer to the specified entry. The next appendXXX() call * will create a child of that entry, forming a new branch. Existing entries * are not modified or deleted. */branch(branchFromId: string): void { if (!this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } this.leafId = branchFromId;}/** * Reset the leaf pointer to null (before any entries). * The next appendXXX() call will create a new root entry (parentId = null). * Use this when navigating to re-edit the first user message. */resetLeaf(): void { this.leafId = null;}resetLeaf() dùng khi message được chọn để sửa lại chính là gốc của cây (chẳng hạn message user đầu tiên của một phiên cũ không có entry thiết lập): entry kế tiếp sẽ là một gốc mới.branchWithSummary( branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean, usage?: Usage,): string { if (branchFromId !== null && !this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } const fromId = this.leafId ?? "root"; this.leafId = branchFromId; const entry: BranchSummaryEntry = { type: "branch_summary", id: generateId(this.byId), parentId: branchFromId, timestamp: new Date().toISOString(), fromId, summary, details, usage, fromHook, }; this._appendEntry(entry); return entry.id;}fromId lấy lá cũ trước khi dời (một lỗi trước đây ghi nhầm điểm đến, CHANGELOG.md:869). Entry tóm tắt trở thành lá mới, nên prompt kế tiếp là con của nó và mô hình đọc tóm tắt ngay trước prompt đó.AgentSession.navigateTree() là phần điều phối mà lệnh /tree gọi. Nó từ chối khi agent đang chạy hoặc đang nén (agent-session.ts:4028), gom các entry của nhánh cũ từ lá cũ tới tổ tiên chung để tóm tắt (branch-summarization.ts:108), cho extension huỷ hoặc tự cung cấp tóm tắt qua session_before_tree, rồi gọi bộ tóm tắt mặc định nếu người dùng chọn tóm tắt. Cách viết tóm tắt thuộc chương Compaction. Phần quyết định lá mới:
// Determine the new leaf position based on target typelet newLeafId: string | null;let editorText: string | undefined;if (targetEntry.type === "message" && targetEntry.message.role === "user") { // User message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; editorText = contentText(targetEntry.message.content, "");} else if (targetEntry.type === "custom_message") { // Custom message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; editorText = contentText(targetEntry.content, "");} else { // Non-user message: leaf = selected node newLeafId = targetId;}user (hay custom_message) nghĩa là "tôi muốn sửa câu này": lá dời về cha của nó và văn bản được đặt lại vào trình soạn thảo. Chọn một entry khác (một câu trả lời assistant chẳng hạn) nghĩa là "tiếp tục sau entry này": lá là chính nó.// Switch leaf (with or without summary)// Summary is attached at the navigation target position (newLeafId), not the old branchlet summaryEntry: BranchSummaryEntry | undefined;if (summaryText) { // Create summary at target position (can be null for root) const summaryId = this.sessionManager.branchWithSummary( newLeafId, summaryText, summaryDetails, fromExtension, summaryUsage, ); summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; // Attach label to the summary entry if (label) { this.sessionManager.appendLabelChange(summaryId, label); }} else if (newLeafId === null) { // No summary, navigating to root - reset leaf this.sessionManager.resetLeaf();} else { // No summary, navigating to non-root this.sessionManager.branch(newLeafId);}// Attach label to target entry when not summarizing (no summary entry to label)if (label && !summaryText) { this.sessionManager.appendLabelChange(targetId, label);}session_tree cho extension (agent-session.ts:4196).Bộ chọn của /tree vẽ từ getTree(): một bản sao nông của toàn bộ cây, con của mỗi nút sắp theo thời gian (cũ trên, mới dưới), entry mồ côi có cha không tồn tại được coi là gốc, và mỗi nút mang nhãn hiện hành (session-manager.ts:1524). Cách vẽ, lọc và phím tắt thuộc chương pi-tui.
Fork và clone sang tệp mới#
Khi một hướng thử đáng thành một công việc riêng, cây trong một tệp trở nên chật. /fork (tách nhánh) tạo một phiên mới kết thúc ngay trước một message user đã chọn, đặt văn bản của message đó vào trình soạn thảo; /clone sao chép nhánh đang hoạt động tới lá hiện tại. Cả hai đi qua cùng một hàm, khác nhau ở position:
async fork( entryId: string, options?: { position?: "before" | "at"; withSession?: (ctx: ReplacedSessionContext) => Promise<void> },): Promise<{ cancelled: boolean; selectedText?: string }> { const position = options?.position ?? "before"; const beforeResult = await this.emitBeforeFork(entryId, { position }); if (beforeResult.cancelled) { return { cancelled: true }; } let targetLeafId: string | null; let selectedText: string | undefined; const selectedEntry = this.session.sessionManager.getEntry(entryId); if (!selectedEntry) { throw new Error("Invalid entry ID for forking"); } if (position === "at") { targetLeafId = selectedEntry.id; } else { if (selectedEntry.type !== "message" || selectedEntry.message.role !== "user") { throw new Error("Invalid entry ID for forking"); } targetLeafId = selectedEntry.parentId; selectedText = extractUserMessageText(selectedEntry.message.content); }fork(entryId) từ bộ chọn message user cho /fork, và fork(leafId, { position: "at" }) cho /clone (interactive-mode.ts:5511). Bộ chọn của /fork liệt kê message user của mọi nhánh, không chỉ nhánh đang hoạt động (agent-session.ts:4221).Với phiên được lưu, phần còn lại (agent-session-runtime.ts:289) có hai nhánh. Nếu message user được chọn là gốc của cây (parentId: null, chẳng hạn trong một phiên cũ), không có gì để chép: một phiên rỗng được tạo với parentSession trỏ về tệp cũ. Ở phiên tạo bằng v1.4.0, message user đầu tiên luôn có cha (các entry thiết lập và message system), nên fork tại đó cho ra một phiên chỉ chứa các entry thiết lập. Còn lại, một SessionManager mới mở lại tệp hiện tại và gọi createBranchedSession(). Phiên chưa từng được lưu (chưa có message nào) thì không fork được.
createBranchedSession(leafId: string): string | undefined { const previousSessionFile = this.sessionFile; const path = this.getBranch(leafId); if (path.length === 0) { throw new Error(`Entry ${leafId} not found`); } // Filter out LabelEntry from path - we'll recreate them from the resolved map. // Because labels are real tree entries, later entries can be children of labels; // removing labels requires re-chaining the retained path to avoid orphaned subtrees. const pathWithoutLabels: SessionEntry[] = []; const replacementByLabelId = new Map<string, string>(); const pendingLabelIds: string[] = []; let pathParentId: string | null = null; for (const entry of path) { if (entry.type === "label") { pendingLabelIds.push(entry.id); continue; } for (const labelId of pendingLabelIds) { replacementByLabelId.set(labelId, entry.id); } pendingLabelIds.length = 0; pathWithoutLabels.push( entry.type === "compaction" ? { ...entry, parentId: pathParentId, firstKeptEntryId: entry.firstKeptEntryId === entry.id ? entry.id : (replacementByLabelId.get(entry.firstKeptEntryId) ?? entry.firstKeptEntryId), } : { ...entry, parentId: pathParentId }, ); pathParentId = entry.id; }label là nút thật của cây, nên bỏ chúng đi sẽ để lại entry mồ côi; vòng lặp nối lại parentId của mỗi entry giữ lại vào entry giữ lại liền trước, và sửa firstKeptEntryId của compaction nếu nó từng trỏ vào một nhãn.Phần sau của hàm (session-manager.ts:1670) tạo header mới với id mới và parentSession là tệp cũ, tạo lại các nhãn còn hiệu lực dưới dạng một chuỗi entry label ở cuối, giữ nguyên timestamp của nhãn, và áp dụng cùng quy tắc tạo tệp trễ như _persist(). Các entry được giữ nguyên id, nên một id trong phiên con trỏ tới đúng nội dung như trong phiên mẹ.
--fork: chép cả tệp sang dự án hiện tại
Cờ --fork <đường dẫn|id> dùng một hàm khác, SessionManager.forkFrom(), và chép mọi entry, cả các nhánh không hoạt động:
// Create new session file with new ID but forked contentif (options?.id !== undefined) { assertValidSessionId(options.id);}const newSessionId = options?.id ?? createSessionId();const timestamp = new Date().toISOString();const fileTimestamp = timestamp.replace(/[:.]/g, "-");const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);// Write new header pointing to source as parent, with updated cwdconst newHeader: SessionHeader = { type: "session", version: CURRENT_SESSION_VERSION, id: newSessionId, timestamp, cwd: resolvedTargetCwd, parentSession: resolvedSourcePath,};writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" });// Copy all non-header entries from sourcefor (const entry of sourceEntries) { if (entry.type !== "session") { appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); }}return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);cwd trong header là thư mục đích, nên phiên mới thuộc về dự án hiện tại. --session trỏ tới một phiên của dự án khác cũng đi đường này, sau khi hỏi người dùng có muốn fork vào thư mục hiện tại không (main.ts:399).Trong cả ba trường hợp, header của tệp con có parentSession (docs/session-format.md:72); bộ chọn session dùng nó để vẽ các phiên thành cây theo quan hệ cha con (session-selector.ts:206).
Nhãn và tên phiên#
Nhãn là dấu trang trên một entry ("checkpoint-1", "trước refactor"), để tìm lại điểm đó trong /tree. Vì entry không được sửa, đặt nhãn không ghi vào entry đích mà ghi thêm một entry label trỏ tới nó:
appendLabelChange(targetId: string, label: string | undefined): string { if (!this.byId.has(targetId)) { throw new Error(`Entry ${targetId} not found`); } const entry: LabelEntry = { type: "label", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), targetId, label, }; this._appendEntry(entry); if (label) { this.labelsById.set(targetId, label); this.labelTimestampsById.set(targetId, entry.timestamp); } else { this.labelsById.delete(targetId); this.labelTimestampsById.delete(targetId); } return entry.id;}label là con của lá như mọi entry khác, nên nó dời lá. Bảng labelsById giữ nhãn hiện hành của mỗi đích; nạp lại tệp thì _buildIndex() dựng lại bảng bằng cách phát lại mọi entry label.Nhãn có thể được đặt từ /tree, từ extension (pi.setLabel()), hoặc khi điều hướng cây kèm tuỳ chọn label: có tóm tắt thì nhãn gắn vào entry tóm tắt, không thì gắn vào entry đích (mục /tree). Nhãn không bao giờ vào ngữ cảnh.
Tên phiên đi theo cùng nguyên tắc ghi thêm, với entry session_info:
/** Append a session info entry (e.g., display name). Returns entry id. */appendSessionInfo(name: string): string { const sanitizedName = name.replace(/[\r\n]+/g, " ").trim(); const entry: SessionInfoEntry = { type: "session_info", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), name: sanitizedName, }; this._appendEntry(entry); return entry.id;}/** Get the current session name from the latest session_info entry, if any. */getSessionName(): string | undefined { // Walk entries in reverse to find the latest session_info entry. // Empty names explicitly clear the session title. Reads fileEntries directly: the footer // calls this on every frame, and getEntries() copies the whole session. for (let i = this.fileEntries.length - 1; i >= 0; i--) { const entry = this.fileEntries[i]; if (entry.type === "session_info") { return entry.name?.trim() || undefined; } } return undefined;}getSessionName() duyệt ngược mảng thay vì gọi getEntries(), vì footer gọi nó ở mỗi khung hình và getEntries() sao chép cả phiên.Tên được đặt bằng /name, cờ --name/-n lúc khởi động, hoặc pi.setSessionName() của extension (docs/session-format.md:199); AgentSession.setSessionName() phát thêm session_info_changed (agent-session.ts:4002). Bộ chọn /resume hiện tên thay cho message đầu tiên, và khi liệt kê, nó cũng lấy session_info cuối cùng, kể cả lần xoá tên (session-manager.ts:828). Cả tên lẫn nhãn đều tính trên toàn tệp chứ không theo nhánh, dù entry ghi chúng nằm trên một nhánh cụ thể: getSessionName() và _buildIndex() duyệt fileEntries, không duyệt getBranch(). Đổi tên ở một nhánh rồi /tree sang nhánh khác không làm tên cũ quay lại.
Liệt kê và mở lại phiên#
Lúc khởi động, createSessionManager() trong main.ts biến các cờ dòng lệnh thành đúng một SessionManager. Nó xét theo thứ tự cố định, cờ đầu tiên khớp thắng (main.ts:357):
| Cờ | Hành vi | Factory |
|---|---|---|
--no-session | Phiên trong bộ nhớ, không ghi tệp | SessionManager.inMemory() |
--fork <đường dẫn|id> | Chép phiên nguồn thành một phiên mới cho cwd hiện tại; có thể kèm --session-id cho id mới | SessionManager.forkFrom() |
--session <đường dẫn|id> | Mở phiên đó; nếu nó thuộc dự án khác thì hỏi có fork vào thư mục hiện tại không | open() hoặc forkFrom() |
--resume, -r | Mở bộ chọn, phiên của dự án hiện tại trước, có thể xem mọi dự án | list(), listAll(), rồi open() |
--continue, -c | Mở phiên mới nhất của cwd, hoặc tạo phiên mới nếu chưa có | SessionManager.continueRecent() |
--session-id <id> | Mở phiên có đúng id đó trong dự án, hoặc tạo phiên mới mang id đó kèm cảnh báo | findById(), rồi open() hoặc create() |
| (không cờ) | Phiên mới | SessionManager.create() |
Các tổ hợp mâu thuẫn bị chặn trước đó: --fork không đi cùng --session, --continue, --resume, --no-session; --session-id không đi cùng --session, --continue, --resume (main.ts:299, docs/cli.md:86). Đối số của --session và --fork có thể là đường dẫn hoặc id, kể cả tiền tố id:
async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise<ResolvedSession> { // If it looks like a file path, resolve it before handing it to the session manager. if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) { return { type: "path", path: resolvePath(sessionArg, cwd) }; } // Exact IDs only require reading session headers. Fall back to the full // metadata listing for prefix matches. const exactLocalMatch = findLocalSessionByExactId(sessionArg, cwd, sessionDir); if (exactLocalMatch) { return exactLocalMatch; } const localSessions = await SessionManager.list(cwd, sessionDir); const localMatch = localSessions.find((s) => s.id.startsWith(sessionArg)); if (localMatch) { return { type: "local", path: localMatch.path }; } // Try global search across all projects const allSessions = await SessionManager.listAll(sessionDir); const globalMatch = allSessions.find((s) => s.id === sessionArg) ?? allSessions.find((s) => s.id.startsWith(sessionArg)); if (globalMatch) { return { type: "global", path: globalMatch.path, cwd: globalMatch.cwd }; } // Not found anywhere return { type: "not_found", arg: sessionArg };}/, \ hay đuôi .jsonl thì coi là đường dẫn. Id chính xác chỉ cần đọc header của từng tệp; tiền tố id mới cần liệt kê đầy đủ. Không tìm thấy trong dự án hiện tại thì tìm trong mọi dự án, và kết quả global dẫn tới câu hỏi fork ở trên.--continue chọn theo thời điểm sửa tệp (mtime), không theo tên tệp:
export function findMostRecentSession(sessionDir: string, cwd?: string): string | null { const resolvedSessionDir = normalizePath(sessionDir); const resolvedCwd = cwd ? resolvePath(cwd) : undefined; try { const files = readdirSync(resolvedSessionDir) .filter((file) => file.endsWith(".jsonl")) .map((file) => join(resolvedSessionDir, file)) .map((path) => ({ path, mtime: statSync(path).mtimeMs })) .sort((a, b) => b.mtime - a.mtime); for (const { path } of files) { const header = readSessionHeaderForDiscovery(path); if (header && (!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(header), resolvedCwd))) return path; } return null; } catch { // Directory access and stat races make recent-session discovery unavailable. return null; }}readSessionHeaderForDiscovery() nuốt lỗi), nên một tệp hỏng không chặn việc tìm các phiên khác.Bộ chọn của --resume và /resume cần nhiều thông tin hơn: buildSessionInfo() đọc cả tệp theo dòng để lấy tên, số message, message đầu tiên và toàn văn để tìm kiếm (session-manager.ts:799). Thời điểm "sửa đổi" dùng để sắp xếp là timestamp của message user hay assistant mới nhất, không phải mtime của tệp, nên đổi tên hay gắn nhãn một phiên cũ không đưa nó lên đầu danh sách (session-manager.ts:856). list() chạy tối đa 10 lần đọc song song và báo tiến độ theo từng đợt để bộ chọn hiện dần; listAll() duyệt mọi thư mục con của ~/.pi/agent/sessions/ (session-manager.ts:1894). Xoá một phiên đơn giản là xoá tệp; bộ chọn có Ctrl+D và dùng lệnh trash khi có (docs/session-format.md:16).
Mở một tệp bằng SessionManager.open() chỉ đọc header trước, trong giới hạn 1 MB, để lấy cwd của phiên (session-manager.ts:1760). Cwd đó, chứ không phải thư mục bạn đang đứng, quyết định settings dự án, extension và trust của runtime. Nếu cwd ghi trong header không còn tồn tại (dự án đã bị xoá hay đổi tên), interactive mode hỏi có tiếp tục ở cwd hiện tại không; các chế độ khác thoát với lỗi (main.ts:697).
Xuất HTML và chia sẻ#
/export ghi phiên thành HTML hoặc JSONL; pi --export tệp.jsonl [đích.html] làm điều tương tự từ dòng lệnh mà không mở phiên (main.ts:640). Hai định dạng khác nhau ở một điểm quan trọng: HTML mang cả cây, JSONL chỉ mang nhánh đang hoạt động.
export async function exportSessionToHtml( sm: SessionManager, state?: AgentState, options?: ExportOptions | string,): Promise<string> { const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; const sessionFile = sm.getSessionFile(); if (!sessionFile) { throw new Error("Cannot export in-memory session to HTML"); } if (!existsSync(sessionFile)) { throw new Error("Nothing to export yet - start a conversation first"); } const entries = sm.getEntries(); const sessionData: SessionData = { header: sm.getHeader(), entries, leafId: sm.getLeafId(), systemPrompt: state?.systemPrompt, tools: state?.tools?.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters })), renderedTools, }; const html = generateHtml(sessionData, opts.themeName);Trang xuất ra có thanh bên vẽ cây và đọc tham số leafId, targetId trên URL để mở đúng một nhánh và cuộn tới một entry (template.js:21). Bản xuất JSONL thì dựng một header mới (không có parentSession) và nối lại parentId dọc theo nhánh (session-export.ts:8), nên nó là một phiên tuyến tính mở được bằng /import hay --session.
/share tải phiên lên để lấy một đường link:
export async function shareSession(context: SessionShareContext): Promise<void> { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-share-")); const jsonlFile = path.join(tempDir, "session.jsonl"); const htmlFile = path.join(tempDir, "session.html"); try { try { exportSessionForShare(jsonlFile, context.session); } catch (error: unknown) { context.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); return; } const destination = context.session.settingsManager.getSessionShareDestination(); if (destination !== "gist") { if (await tryShareViaRadius(jsonlFile, context)) return; if (destination === "radius") { context.showError("Radius authentication is not configured. Run '/login radius' first."); return; } } try { await context.session.exportToHtml(htmlFile, { themeName: theme.name }); } catch (error: unknown) { context.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); return; } await shareViaGist(htmlFile, context);custom cuối với customType: "pi.share" chứa system prompt và schema tool cho trang xem (session-share.ts:24). Đường gist tạo một gist riêng tư từ bản xuất HTML bằng gh gist create --public=false, và link xem có dạng https://pi.dev/session/#<gistId> (config.ts:594).Quyết định thiết kế#
Một tệp, chỉ ghi thêm, một dòng mỗi entry
Phương án khác là một cơ sở dữ liệu, hay một tệp cho mỗi nhánh. JSONL chỉ ghi thêm thắng ở ba điểm thực tế. An toàn khi sập: mỗi lần ghi là một dòng; pi bị giết giữa chừng thì tệp mất tối đa dòng cuối, và lần nạp sau sửa ký tự xuống dòng còn thiếu. Không mất lịch sử: rẽ nhánh, nén, sửa ngữ cảnh đều là dòng mới, nên mọi thứ từng xảy ra vẫn đọc được, thống kê chi phí cộng được trên toàn bộ. Dễ dùng bằng công cụ thường: grep, jq, diff đều chạy được trên tệp, và định dạng được tài liệu hoá để chương trình khác đọc. Cái giá: tệp chỉ lớn lên, mở một phiên phải đọc cả tệp (nên mới đọc theo khối), và mỗi yêu cầu phải đi lại đường từ lá về gốc.
Cây trong cùng tệp
Rẽ nhánh tại chỗ có từ 0.31.0 (CHANGELOG.md:5152). Giữ mọi nhánh trong một tệp nghĩa là quay lại một hướng đã bỏ chỉ là dời lá, không cần tìm tệp; và bản tóm tắt nhánh nối hai hướng thử bằng chính một entry của cây. Khi một nhánh đáng tách riêng, /fork và /clone đưa nó sang tệp mới, giữ nguyên id entry và ghi parentSession để vẫn lần ra được nguồn gốc.
Mọi thay đổi trạng thái đều là entry
Mô hình, mức suy nghĩ, tên, nhãn không phải trường của header mà là entry. Lý do: chúng đổi giữa phiên và có thể khác nhau giữa các nhánh. Resume chỉ cần đọc giá trị mới nhất trên đường đi. Cũng vì vậy mà compaction mang theo ảnh chụp systemMessage: phần ngữ cảnh giữ lại không cần các message system cũ, vì trạng thái prompt tại điểm nén đã được gộp vào ảnh chụp (docs/session-format.md:248).
Id ngắn, duy nhất trong một tệp
Tám ký tự hex đủ để không trùng trong một phiên (có kiểm tra), ngắn hơn nhiều so với UUID khi mỗi dòng mang hai id (id, parentId) và nhiều entry còn trỏ thêm (targetId, firstKeptEntryId, fromId). Id chỉ cần duy nhất trong tệp; id toàn cục là id phiên trong header.
Cạm bẫy và trường hợp biên#
- Nhãn dời lá. Entry
labellà con của lá hiện tại như mọi entry khác, nên nó nằm trên nhánh đang hoạt động và entry kế tiếp sẽ là con của nó. Nó không sinh message, nhưng công cụ nào đi theoparentIdphải tính tới nó, nhưcreateBranchedSession()phải nối lại cây khi bỏ nhãn. - Dòng hỏng cắt đứt lịch sử. Dòng không parse được bị bỏ lặng lẽ. Nếu đó là một entry giữa nhánh, entry con của nó có
parentIdtrỏ vào hư không:buildSessionPath()dừng ở đó, nên mọi thứ trước điểm hỏng biến khỏi ngữ cảnh mà không báo lỗi, còngetTree()coi entry con là một gốc mồ côi (session-manager.ts:1544). Sửa tệp bằng tay cần cẩn thận. - Tiền tố id chọn phiên gần nhất.
--session abkhông báo mơ hồ khi nhiều phiên có id bắt đầu bằngab; nó lấy phiên đầu tiên trong danh sách đã sắp theo hoạt động gần nhất (main.ts:266). - Compaction không giữ gì.
firstKeptEntryIdbằng chính id của compaction nghĩa là không giữ entry nào phía trước (session-manager.ts:1278, docs/session-format.md:131). customkhông vào ngữ cảnh. Extension muốn mô hình đọc thứ gì phải dùngcustom_message;customchỉ để lưu trạng thái.- Hai loại timestamp. Entry dùng chuỗi ISO, message bên trong dùng số mili giây. Thứ tự hai loại có thể ngược nhau, như message
systemvàuserở ví dụ JSONL. getEntries()sao chép. Nó lọc toàn bộfileEntriesmỗi lần gọi; mã gọi thường xuyên nên tránh, nhưgetSessionName()đã làm (session-manager.ts:1320).- Phiên trong bộ nhớ không xuất HTML được (index.ts:247), và phiên mới chưa có message nào thì chưa có tệp để xuất hay fork.
- Có thể có nhiều gốc.
resetLeaf()vàbranchWithSummary(null, ...)làm entry kế tiếp thành một gốc mới (docs/session-format.md:211); công cụ đọc tệp không nên giả định chỉ có mộtparentId: null.
Đọc code ở đâu#
| # | Tệp và vùng | Đọc để hiểu |
|---|---|---|
| 1 | packages/coding-agent/docs/session-format.md, packages/coding-agent/docs/sessions.md | Định dạng và cách dùng, trước khi đọc code. |
| 2 | session-manager.ts:41 | Header, mười một loại entry, projection, SessionInfo. |
| 3 | session-manager.ts:976 | Constructor, mở tệp, phiên mới, chỉ mục, tạo tệp trễ, appendMessage(). |
| 4 | session-manager.ts:1216 | Các hàm append*() còn lại, kể cả appendCompaction() và appendContextEdit(). |
| 5 | session-manager.ts:390 | Dựng ngữ cảnh: đường đi, compaction, entry thành message, context edit. |
| 6 | session-manager.ts:1400 | Lá, con, nhãn, getBranch(), getTree(). |
| 7 | session-manager.ts:1569 | branch(), branchWithSummary(), createBranchedSession(). |
| 8 | session-manager.ts:1750 | Factory: create, open, continueRecent, inMemory, forkFrom, findById, list, listAll. |
| 9 | session-manager.ts:585 | Thư mục theo cwd, đọc tệp theo khối, đọc header có giới hạn, thông tin cho bộ chọn. |
| 10 | session-manager.ts:286, packages/coding-agent/src/migrations.ts | Migration định dạng và migration lúc khởi động. |
| 11 | agent-session.ts:4009 | navigateTree() cho /tree; danh sách message để fork. |
| 12 | agent-session-runtime.ts:262 | /fork và /clone. |
| 13 | main.ts:232 | Cờ dòng lệnh thành SessionManager. |
| 14 | packages/coding-agent/src/core/session-export.ts, packages/coding-agent/src/core/export-html/index.ts, packages/coding-agent/src/modes/interactive/session-share.ts | Xuất JSONL, xuất HTML, /share. |
Tóm tắt#
- Một session là một tệp JSONL dưới
~/.pi/agent/sessions/--cwd--/: một dòng header, rồi các entry chỉ ghi thêm, nối thành cây bằngid/parentId. - Có mười một loại entry; bốn loại ảnh hưởng tới ngữ cảnh (
message,compaction,branch_summary,custom_message), một loại sửa ngữ cảnh (context_edit), sáu loại còn lại chỉ mang trạng thái hay metadata. - Lá là vị trí hiện tại; entry mới luôn là con của lá. Ngữ cảnh là đường đi từ gốc tới lá, áp dụng compaction mới nhất và context edit mới nhất cho mỗi đích, được tính lại trước mỗi yêu cầu.
- Tệp chỉ được tạo khi có message
userđầu tiên; migration v1 → v2 → v3 là lần duy nhất tệp bị viết lại. /treedời lá trong cùng tệp, có thể kèm tóm tắt nhánh vừa rời;/forkvà/clonechép một đường đi sang tệp mới;--forkchép cả tệp sang dự án hiện tại.- Nhãn và tên phiên cũng là entry, tính trên toàn tệp. Xuất HTML mang cả cây; xuất JSONL và chia sẻ qua Radius chỉ mang nhánh đang hoạt động.
9Công cụ và system prompt#
Vai trò#
Mô hình ngôn ngữ chỉ sinh ra văn bản. Nó đọc được file hay chạy được lệnh là nhờ harness: harness khai báo cho mô hình một danh sách tool (tên, mô tả, JSON Schema của tham số), mô hình trả lời bằng một lời gọi tool, harness chạy lời gọi đó rồi gửi lại kết quả tool. Chương này nói về hai thứ mô hình thấy đầu tiên trong mỗi request của Pi: bộ tool có sẵn và system prompt.
Các tool có sẵn nằm trong packages/coding-agent/src/core/tools/. Có tám tool: bốn tool bật mặc định là read, bash, edit, write; ba tool chỉ đọc là grep, find, ls; và powershell cho Windows (tools/index.ts:95). codemode và tool_search của Pi 1.0 không nằm ở đây mà là built-in extension, xem chương Codemode. Mỗi tool là một ToolDefinition: ngoài phần mô hình thấy, nó còn mang phần đóng góp cho system prompt và phần vẽ giao diện (renderer).
Ai dùng các tool này? AgentSession (chương AgentSession) tạo definition cho cả tám tool khi dựng runtime, bọc chúng thành AgentTool của pi-agent-core, rồi giao danh sách tool đang bật cho Agent. Vòng lặp agent (agent loop) kiểm tra đối số, gọi hook, chạy execute() và gói kết quả thành message (chương pi-agent-core). Chương này đi vào bên trong execute() của từng tool, rồi vào system-prompt.ts, nơi prompt được ghép từ tool đang bật, file AGENTS.md, skills và thư mục làm việc.
Ngoài phạm vi chương: chặn hay sửa lời gọi tool bằng extension (chương Extensions), cách session lưu message (chương Session), codemode, MCP và tool search (chương Codemode).
Mô hình tư duy#
Một tool có ba mặt hướng về ba người đọc khác nhau: mô hình, system prompt và giao diện. Kết quả của nó cũng tách làm nhiều phần, mỗi phần đi một nơi:
coding-agent pi-agent-core provider ToolDefinition AgentTool (mô hình thấy) ┌──────────────────────────┐ wrap ┌──────────────────┐ ┌──────────────┐ │ name, label, description │ ─────▶ │ name, description│ ─▶ │ name │ │ parameters (TypeBox) │ │ parameters │ │ description │ │ execute(..., ctx) │ │ execute(...) │ │ JSON Schema │ │ promptSnippet ───────────┼──┐ └──────────────────┘ └──────────────┘ │ promptGuidelines ────────┼──┴──▶ system prompt: <tools>, <rules> │ renderCall, renderResult ┼─────▶ giao diện (TUI) └──────────────────────────┘ execute() trả về AgentToolResult: content ──▶ mô hình (text, ảnh) details ──▶ giao diện và file session (diff, thông tin cắt bớt) structuredContent ──▶ code gọi tool theo lập trình (codemode), không tới mô hình isError ──▶ mô hình thấy đây là kết quả lỗi
Các từ dùng trong phần còn lại của chương:
- ToolDefinition
- Kiểu của coding-agent (cũng là kiểu extension dùng với
registerTool()).execute()nhận thêmctx, ngữ cảnh extension của lời gọi: thư mục làm việc, mô hình, session. - AgentTool
- Kiểu của pi-agent-core, thứ vòng lặp thực sự gọi.
wrapToolDefinition()chuyển một ToolDefinition thành AgentTool. - Operations
- Mỗi tool nhận một interface backend:
ReadOperations,BashOperations,EditOperations... Mặc định là hệ thống file và tiến trình cục bộ; thay interface này để chạy tool qua SSH, trong container hay micro-VM. - promptSnippet, promptGuidelines
- Một dòng mô tả tool trong mục
<tools>của system prompt, và các gạch đầu dòng tool thêm vào mục<rules>khi nó đang bật. - Cắt bớt (truncation)
- Giới hạn chung cho output tool: 2.000 dòng hoặc 50KB, cái nào chạm trước. Phần bị cắt được thay bằng một dòng hướng dẫn mô hình cách lấy tiếp.
- Hàng đợi ghi theo file (file mutation queue)
- Khoá theo đường dẫn thật của file, để hai lời gọi
edit/writechạy song song không ghi đè lên nhau.
Data model: ToolDefinition và kết quả tool#
Định nghĩa đầy đủ của một tool nằm trong kiểu extension. Phần đầu là thứ mô hình và system prompt thấy, phần cuối là thực thi và vẽ giao diện:
export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = unknown, TState = any> { /** Tool name (used in LLM tool calls) */ name: string; /** Human-readable label for UI */ label: string; /** Description for LLM */ description: string; /** Optional one-line snippet for the Available tools section in the default system prompt. Custom tools are omitted from that section when this is not provided. */ promptSnippet?: string; /** Optional guideline bullets appended to the default system prompt Guidelines section when this tool is active. */ promptGuidelines?: string[]; /** Parameter schema (TypeBox) */ parameters: TParams; /** Optional provider-side constrained sampling request for this tool. Set false to explicitly disable it, equivalent to leaving it undefined. */ constrainedSampling?: false | ConstrainedSamplingConfig; /** Controls whether ToolExecutionComponent renders the standard colored shell or the tool renders its own framing. */ renderShell?: "default" | "self"; /** Optional compatibility shim to prepare raw tool call arguments before schema validation. Must return an object conforming to TParams. */ prepareArguments?: (args: unknown) => Static<TParams>; /** Execute the tool. */ execute( toolCallId: string, params: Static<TParams>, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback<TDetails> | undefined, ctx: ExtensionToolContext, ): Promise<AgentToolResult<TDetails>>; /** Custom rendering for tool call display */ renderCall?: (args: Static<TParams>, theme: Theme, context: ToolRenderContext<TState, Static<TParams>>) => Component; /** Custom rendering for tool result display */ renderResult?: ( result: AgentToolResult<TDetails>, options: ToolRenderResultOptions, theme: Theme, context: ToolRenderContext<TState, Static<TParams>>, ) => Component;}promptSnippet là tuỳ chọn, nhưng thiếu nó thì tool không xuất hiện trong mục <tools> của prompt (dòng 586). prepareArguments (dòng 598) là chỗ edit sửa đối số sai dạng trước khi kiểm tra schema.Các trường lược bớt (dòng 599–648) là của Pi 1.0: outputSchema cho codemode, exposure và namespace cho MCP, executionMode để buộc chạy tuần tự. Tool có sẵn không dùng exposure hay executionMode; ba tool read, bash, powershell khai báo outputSchema.
Kết quả của execute() là một AgentToolResult, định nghĩa trong pi-agent-core:
/** Final or partial result produced by a tool. */export interface AgentToolResult<T = JsonValue | undefined> { /** Text or image content returned to the model. */ content: (TextContent | ImageContent)[]; /** Arbitrary structured details for logs or UI rendering. */ details: T; /** * Machine-readable result matching the tool's `outputSchema`, for programmatic callers. Not sent * to the model; `content` remains the model-facing result. */ structuredContent?: JsonValue; /** Usage from the final tool execution itself, if available. Not used for main LLM context accounting. */ usage?: Usage; /** * Report a failure without throwing. The model sees `content` as an error result, like a thrown * error, but `details` and `structuredContent` are kept for the UI and programmatic callers. */ isError?: boolean; /** * Hint that the agent should stop after the current tool batch. * Early termination only happens when every finalized tool result in the batch sets this to true. */ terminate?: boolean;}isError cho phép báo lỗi mà không throw: mô hình vẫn thấy một kết quả lỗi, còn details được giữ cho giao diện. bash dùng đường này khi lệnh trả mã khác 0.Khi lời gọi xong, vòng lặp gói kết quả thành một ToolResultMessage của pi-ai. Đây là thứ được nối vào transcript và ghi vào file session:
export type ToolResultMessage<TDetails = JsonValue> = IsJsonCompatible<TDetails> extends true ? { role: "toolResult"; toolCallId: string; toolName: string; content: (TextContent | ImageContent)[]; // Supports text and images details?: JsonRepresentation<TDetails>; /** Usage from the tool execution itself, if available. Not part of main LLM context accounting. */ usage?: Usage; /** Calls this tool made to other tools. Kept for the session record; not sent to the model. */ nestedCalls?: NestedToolCalls; isError: boolean; /** Unix timestamp in milliseconds when the result was created. */ timestamp: number; /** Milliseconds the tool's execution took, measured with a monotonic clock. Absent for legacy results. */ durationMs?: number; } : never;Để ý là structuredContent không có trong message: createToolResultMessage() chỉ chép content, details, usage, isError và durationMs (agent-loop.ts:940–954). Đến lượt mình, adapter của provider chỉ gửi đi content và cờ lỗi của message. Với Anthropic Messages API, khối tool_result được dựng từ đúng hai trường đó (anthropic-messages.ts:1339–1346). details vì thế không tốn token nào; nó chỉ phục vụ giao diện, file session và extension.
Ví dụ minh hoạ: một ToolResultMessage của edit như được ghi vào session (rút gọn)
{
"role": "toolResult",
"toolCallId": "toolu_01",
"toolName": "edit",
"content": [{ "type": "text", "text": "Successfully replaced 1 block(s) in src/server.ts." }],
"details": {
"diff": "-12 const port = 3000;\n+12 const port = Number(process.env.PORT ?? 3000);",
"patch": "--- src/server.ts\n+++ src/server.ts\n@@ ...",
"firstChangedLine": 12
},
"isError": false,
"durationMs": 4,
"timestamp": 1761100000000
}
details cho giao diện vẽ.Bộ tool mặc định và cách chọn tool#
tools/index.ts liệt kê tám tên tool và cung cấp các bộ dựng sẵn cho SDK: bộ “coding” bốn tool và bộ chỉ đọc bốn tool.
export function createCodingToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] { return [ createReadToolDefinition(cwd, options?.read), createBashToolDefinition(cwd, options?.bash), createEditToolDefinition(cwd, options?.edit), createWriteToolDefinition(cwd, options?.write), ];}export function createReadOnlyToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] { return [ createReadToolDefinition(cwd, options?.read), createGrepToolDefinition(cwd, options?.grep), createFindToolDefinition(cwd, options?.find), createLsToolDefinition(cwd, options?.ls), ];}Bộ tool lúc khởi động do createAgentSession() quyết định. Mặc định là DEFAULT_TOOL_NAMES (settings-manager.ts:85), trừ khi setting defaultTools đổi nó. Tuỳ chọn tools của SDK (cờ --tools của CLI) hoặc thay hẳn danh sách, hoặc chỉ cộng trừ khi mọi phần tử đều có dạng +name/-name; excludeTools lọc cuối cùng:
const toolListError = options.tools ? getToolListError(options.tools) : undefined;if (toolListError) throw new Error(`Invalid tools option: ${toolListError}`);const defaultToolNames = options.noTools ? [] : (settingsManager.getDefaultTools() ?? DEFAULT_TOOL_NAMES);// A `tools` list of only `+name`/`-name` entries changes the default selection instead of// replacing it, like the `defaultTools` setting.const toolModifiers = options.tools?.some(isToolModifier) ? options.tools : undefined;const selectedToolNames = toolModifiers ? applyToolModifiers(defaultToolNames, toolModifiers) : options.tools;const allowedToolNames = toolModifiers ? options.noTools === "all" ? selectedToolNames : undefined : (options.tools ?? (options.noTools === "all" ? [] : undefined));const excludedToolNames = options.excludeTools;const isExcludedTool = excludedToolNames ? createToolNameMatcher(excludedToolNames) : undefined;const initialActiveToolNames = (selectedToolNames ?? defaultToolNames).filter((name) => !isExcludedTool?.(name));Ở phía AgentSession, _buildRuntime() luôn tạo definition cho cả tám tool, truyền vào các setting liên quan (tự thu nhỏ ảnh, tiền tố lệnh shell, đường dẫn shell), nhưng chỉ bật những tên được chọn:
const autoResizeImages = this.settingsManager.getImageAutoResize();const shellCommandPrefix = this.settingsManager.getShellCommandPrefix();const shellPath = this.settingsManager.getShellPath();const baseToolDefinitions = this._baseToolsOverride ? Object.fromEntries( Object.entries(this._baseToolsOverride).map(([name, tool]) => [ name, createToolDefinitionFromAgentTool(tool), ]), ) : createAllToolDefinitions(this._cwd, { read: { autoResizeImages }, bash: { commandPrefix: shellCommandPrefix, shellPath }, });const defaultActiveToolNames = this._baseToolsOverride ? Object.keys(this._baseToolsOverride) : ["read", "bash", "edit", "write"];const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames;this._refreshToolRegistry({ activeToolNames: baseActiveToolNames, includeAllExtensionTools: options.includeAllExtensionTools,});Sau đó _refreshToolRegistry() gộp tool có sẵn với tool của extension và SDK (tool extension cùng tên sẽ thay tool có sẵn), gom promptSnippet và promptGuidelines của từng tool vào hai map cho system prompt, rồi bọc tất cả bằng wrapRegisteredTools() (agent-session.ts:3583–3618). Hàm bọc rất mỏng:
export function wrapToolDefinition<TDetails = unknown>( definition: ToolDefinition<any, TDetails>, ctxFactory?: ToolContextFactory,): AgentTool<any, TDetails> { return { name: definition.name, label: definition.label, description: definition.description, parameters: definition.parameters, outputSchema: definition.outputSchema, constrainedSampling: definition.constrainedSampling, prepareArguments: definition.prepareArguments, executionMode: definition.executionMode, execute: (toolCallId, params, signal, onUpdate, ctx?: ExtensionToolContext) => definition.execute( toolCallId, params, signal, onUpdate, ctx ?? (ctxFactory?.(toolCallId, signal) as ExtensionToolContext), ), };}ctxFactory do ExtensionRunner.createToolContext() cung cấp (extensions/wrapper.ts:17–21), nên tool có sẵn và tool extension nhận cùng một loại ngữ cảnh.Nhờ ctx này, mọi tool có sẵn tính đường dẫn theo ctx?.cwd || cwd tại thời điểm gọi chứ không chỉ theo cwd lúc tạo tool (ví dụ edit.ts:161), và read biết mô hình hiện tại có nhận ảnh hay không.
Từ dòng lệnh, --tools read,grep,find,ls cho một bộ tool không ghi được file (docs/cli.md:114); --tools +codemode,-write cộng trừ trên bộ mặc định; --exclude-tools tắt theo mẫu; --no-builtin-tools tắt tool có sẵn nhưng giữ tool extension; --no-tools tắt hết (docs/cli.md:119–128).
Cắt bớt output: truncate.ts#
Vấn đề: output tool là thứ ngốn cửa sổ ngữ cảnh (context window) nhiều nhất. Một lệnh cat trên file log 30MB, hay một npm test in mười nghìn dòng, đủ làm request kế tiếp vượt giới hạn của mô hình. Và đã vào transcript thì output đó được gửi lại ở mọi lượt (turn) sau. Pi đặt một bộ giới hạn chung cho mọi tool, trong một file:
/** * Shared truncation utilities for tool outputs. * * Truncation is based on two independent limits - whichever is hit first wins: * - Line limit (default: 2000 lines) * - Byte limit (default: 50KB) * * Never returns partial lines (except bash tail truncation edge case). */export const DEFAULT_MAX_LINES = 2000;export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KBexport const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match lineCó hai hướng cắt. truncateHead() giữ phần đầu, dùng cho read, grep, find, ls, vì phần đầu của file hay của danh sách kết quả là phần có ích nhất. truncateTail() giữ phần cuối, dùng cho bash, vì lỗi và kết quả cuối cùng của một lệnh nằm ở cuối output.
export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult { const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const totalBytes = Buffer.byteLength(content, "utf-8"); const lines = splitLinesForCounting(content); const totalLines = lines.length; // Check if first line alone exceeds byte limit const firstLineBytes = Buffer.byteLength(lines[0], "utf-8"); if (firstLineBytes > maxBytes) { return { content: "", truncated: true, truncatedBy: "bytes", totalLines, totalBytes, outputLines: 0, outputBytes: 0, lastLinePartial: false, firstLineExceedsLimit: true, maxLines, maxBytes, }; } // Collect complete lines that fit const outputLinesArr: string[] = []; let outputBytesCount = 0; let truncatedBy: "lines" | "bytes" = "lines"; for (let i = 0; i < lines.length && i < maxLines; i++) { const line = lines[i]; const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline if (outputBytesCount + lineBytes > maxBytes) { truncatedBy = "bytes"; break; } outputLinesArr.push(line); outputBytesCount += lineBytes; } // If we exited due to line limit if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { truncatedBy = "lines"; }Không bao giờ trả về nửa dòng, trừ một ngoại lệ. Khi cắt đầu mà riêng dòng đầu tiên đã lớn hơn 50KB (một file JSON minified chẳng hạn), hàm trả chuỗi rỗng kèm firstLineExceedsLimit: true; read khi đó trả cho mô hình một gợi ý dùng sed -n ... | head -c qua bash. Khi cắt đuôi mà dòng cuối đã lớn hơn giới hạn, hàm lấy phần cuối của chính dòng đó, cắt đúng ranh giới ký tự UTF-8:
export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult { const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; // Work backwards from the end const outputLinesArr: string[] = []; let outputBytesCount = 0; let truncatedBy: "lines" | "bytes" = "lines"; let lastLinePartial = false; for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) { const line = lines[i]; const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline if (outputBytesCount + lineBytes > maxBytes) { truncatedBy = "bytes"; // Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes, // take the end of the line (partial) if (outputLinesArr.length === 0) { const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes); outputLinesArr.unshift(truncatedLine); outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8"); lastLinePartial = true; } break; } outputLinesArr.unshift(line); outputBytesCount += lineBytes; }TruncationResult ghi đủ thông tin để dựng thông báo: tổng số dòng và byte, số dòng và byte giữ lại, giới hạn nào bị chạm (truncate.ts:15–38). Mỗi tool dùng nó để nối vào output một dòng hướng dẫn hành động, ví dụ [Showing lines 1-2000 of 8412. Use offset=2001 to continue.] cho read, hay [Showing lines 6413-8412 of 8412. Full output: /tmp/pi-bash-….log] cho bash. Thông báo không chỉ nói là đã cắt mà nói luôn bước tiếp theo, nên mô hình tự lấy được phần còn lại mà harness không phải can thiệp. File còn có truncateLine() cắt một dòng xuống 500 ký tự cho grep (truncate.ts:268), và truncateMiddle() giữ đầu và đuôi “như Codex”, hiện chỉ dùng cho output của tool MCP (mcp/tools.ts:185).
read: đọc text và ảnh#
Schema của read chỉ có ba tham số. Phần đóng góp cho system prompt nằm ngay cạnh, cùng file:
const readSchema = Type.Object({ path: Type.String({ description: "Path to the file to read (relative or absolute)" }), offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })), limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),});export const readToolSystemPromptContribution = { snippet: "Read file contents", guidelines: ["Use read to examine files instead of cat or sed."],} as const;Mô tả mà mô hình đọc được dựng từ chính các hằng số cắt bớt, nên con số trong mô tả không thể lệch khỏi hành vi:
return { name: "read", label: "read", description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, promptSnippet: readToolSystemPromptContribution.snippet, promptGuidelines: [...readToolSystemPromptContribution.guidelines], parameters: readSchema, outputSchema: readOutputSchema, constrainedSampling: { type: "json_schema", strict: "prefer" },constrainedSampling xin provider dùng chế độ strict, tức là sinh đối số bị ràng buộc theo JSON Schema, khi API có hỗ trợ; "prefer" là muốn có nhưng không bắt buộc, khác "require" (pi-ai types.ts:745–756). Bốn tool mặc định đều bật nó; grep, find, ls thì không.
Phân giải đường dẫn
Mọi tool file đều đi qua path-utils.ts. resolveToCwd() gọi resolvePath() với hai tuỳ chọn: bỏ ký tự @ ở đầu (mô hình hay chép nguyên cú pháp @file của người dùng) và đổi các biến thể khoảng trắng Unicode thành dấu cách thường. normalizePath() còn mở rộng ~ và nhận URL file://:
export function normalizePath(input: string, options: PathInputOptions = {}): string { let normalized = options.trim ? input.trim() : input; if (options.normalizeUnicodeSpaces) { normalized = normalized.replace(UNICODE_SPACES, " "); } if (options.stripAtPrefix && normalized.startsWith("@")) { normalized = normalized.slice(1); } if (process.platform === "win32") { normalized = normalizeWindowsShellPath(normalized); } if (options.expandTilde ?? true) { const home = options.homeDir ?? homedir(); if (normalized === "~") return home; if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) { return join(home, normalized.slice(2)); } } if (/^file:\/\//.test(normalized)) { return fileURLToPath(normalized); } return normalized;}export function resolvePath(input: string, baseDir: string = process.cwd(), options: PathInputOptions = {}): string { const normalized = normalizePath(input, options); const normalizedBaseDir = normalizePath(baseDir); return isAbsolute(normalized) ? nodeResolvePath(normalized) : nodeResolvePath(normalizedBaseDir, normalized);}Dòng 106 đáng chú ý: đường dẫn tuyệt đối được dùng nguyên, đường dẫn tương đối được nối vào thư mục làm việc, và .. không bị chặn. Không có sandbox nào ở tầng tool. Riêng read thử thêm vài biến thể khi file không tồn tại, vì tên ảnh chụp màn hình trên macOS chứa ký tự mà người dùng không gõ ra được:
export async function resolveReadPathAsync(filePath: string, cwd: string): Promise<string> { const resolved = resolveToCwd(filePath, cwd); if (await pathExists(resolved)) { return resolved; } // Try macOS AM/PM variant (narrow no-break space before AM/PM) const amPmVariant = tryMacOSScreenshotPath(resolved); if (amPmVariant !== resolved && (await pathExists(amPmVariant))) { return amPmVariant; } // Try NFD variant (macOS stores filenames in NFD form) const nfdVariant = tryNFDVariant(resolved); if (nfdVariant !== resolved && (await pathExists(nfdVariant))) { return nfdVariant; } // Try curly quote variant (macOS uses U+2019 in screenshot names) const curlyVariant = tryCurlyQuoteVariant(resolved); if (curlyVariant !== resolved && (await pathExists(curlyVariant))) { return curlyVariant; } // Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran") const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant); if (nfdCurlyVariant !== resolved && (await pathExists(nfdCurlyVariant))) { return nfdCurlyVariant; } return resolved;}File text
const allLines = textContent.split("\n");const totalFileLines = allLines.length;// Apply offset if specified. Convert from 1-indexed input to 0-indexed array access.const startLine = offset ? Math.max(0, offset - 1) : 0;const startLineDisplay = startLine + 1;// Check if offset is out of bounds.if (startLine >= allLines.length) { throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);}if (limit !== undefined) { const endLine = Math.min(startLine + limit, allLines.length); selectedContent = allLines.slice(startLine, endLine).join("\n"); userLimitedLines = endLine - startLine;} else { selectedContent = allLines.slice(startLine).join("\n");}// Apply truncation, respecting both line and byte limits.const truncation = truncateHead(selectedContent);let outputText: string;if (truncation.firstLineExceedsLimit) { // First line alone exceeds the byte limit. Point the model at a bash fallback. const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8")); outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`; details = { truncation };} else if (truncation.truncated) { // Truncation occurred. Build an actionable continuation notice. const endLineDisplay = startLineDisplay + truncation.outputLines - 1; const nextOffset = endLineDisplay + 1; outputText = truncation.content; if (truncation.truncatedBy === "lines") { outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`; } else { outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`; } details = { truncation };offset đánh số từ 1. limit của mô hình được áp trước; giới hạn 2.000 dòng/50KB áp sau, nên limit: 5000 vẫn chỉ trả về tối đa 2.000 dòng kèm thông báo đọc tiếp.Output không kèm số dòng. Thứ mô hình nhận đúng là nội dung file, nên đoạn nó chép sang oldText của edit cũng là văn bản thật của file, không phải gỡ tiền tố số dòng ra trước.
Ảnh
read không đoán loại file theo đuôi. detectSupportedImageMimeTypeFromFile() đọc 4.100 byte đầu và nhận diện JPEG, PNG (bỏ qua PNG động), GIF, WebP, BMP theo chữ ký nhị phân (utils/mime.ts:6–23). Có MIME thì đi nhánh ảnh:
const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(absolutePath) : undefined;let content: (TextContent | ImageContent)[];let details: ReadToolDetails | undefined;const nonVisionImageNote = getNonVisionImageNote(ctx?.model);if (mimeType) { // Read image as binary. const buffer = await ops.readFile(absolutePath); const processed = await processImage(buffer, mimeType, { autoResizeImages, resizeOptions: ctx?.model?.inputLimits?.images?.resize ?? fallbackResizeOptions, }); if (!processed.ok) { let textNote = `Read image file [${mimeType}]\n${processed.message}`; if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`; content = [{ type: "text", text: textNote }]; } else { let textNote = `Read image file [${processed.mimeType}]`; if (processed.hints.length > 0) textNote += `\n${processed.hints.join("\n")}`; if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`; content = [ { type: "text", text: textNote }, { type: "image", data: processed.data, mimeType: processed.mimeType }, ]; }inputLimits.images.resize), nếu không thì dùng mặc định.processImage() đổi BMP sang PNG, rồi thu nhỏ về tối đa 2000×2000 điểm ảnh và 4,5MB base64, “dưới giới hạn 5MB của Anthropic” (image-resize-core.ts:31–39). Khi ảnh bị thu nhỏ, dòng text đi kèm báo kích thước gốc và hệ số nhân toạ độ (image-resize.ts:115), để mô hình nói “nút ở toạ độ (840, 220)” thì toạ độ đó vẫn đúng trên ảnh gốc. Nếu mô hình hiện tại không nhận ảnh, text kèm thêm một câu báo ảnh sẽ bị bỏ khỏi request (read.ts:79–84). Cuối cùng, structuredContent của read là chuỗi text hoặc một đối tượng ảnh cho script codemode (read.ts:216).
bash: chạy lệnh, timeout, luồng output#
const bashSchema = Type.Object({ command: Type.String({ description: "Shell command to execute" }), timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),});export const bashToolSystemPromptContribution = { snippet: "Execute bash commands (ls, grep, find, etc.)", guidelines: ["You can inspect PI_* environment variables for current model and session details."],} as const;timeout tính bằng giây, phải là số dương hữu hạn, tối đa khoảng 2.147.483 giây, giới hạn của setTimeout (bash.ts:27–38).Mô tả mô hình thấy báo trước rằng output bị cắt còn 2.000 dòng cuối hoặc 50KB và output đầy đủ được lưu ra file tạm (bash.ts:255). bash và powershell dùng chung một hàm dựng, createShellToolDefinition(), chỉ khác cấu hình.
Sinh tiến trình
Backend mặc định là createLocalShellOperations(). Shell được chọn bởi getShellConfig(): shellPath trong settings nếu có, trên Unix thì /bin/bash, rồi bash trên PATH, cuối cùng là sh; trên Windows thì tìm Git Bash (utils/shell.ts:86–139). Lệnh chạy dưới dạng bash -c "<lệnh>":
const shellConfig = resolveShellConfig();try { await fsAccess(cwd, constants.F_OK);} catch { throw new Error(`Working directory does not exist: ${cwd}\nCannot execute ${shellName} commands.`);}const commandFromStdin = shellConfig.commandTransport === "stdin";const child = spawn(shellConfig.shell, commandFromStdin ? shellConfig.args : [...shellConfig.args, command], { cwd, detached: process.platform !== "win32", env: env ?? getShellEnv(), stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"], windowsHide: true,});if (commandFromStdin) { child.stdin?.on("error", () => {}); child.stdin?.end(command);}if (child.pid) trackDetachedChildPid(child.pid);let timedOut = false;let timeoutHandle: NodeJS.Timeout | undefined;const onAbort = () => { if (child.pid) killProcessTree(child.pid);};detached: true trên Unix làm shell thành trưởng một nhóm tiến trình (process group) mới, nhờ vậy về sau giết được cả cây. stdin là "ignore": lệnh nào chờ nhập từ bàn phím sẽ nhận EOF ngay chứ không treo.// Set timeout if provided.if (timeoutMs !== undefined) { timeoutHandle = setTimeout(() => { timedOut = true; if (child.pid) killProcessTree(child.pid); }, timeoutMs);}// Stream stdout and stderr.child.stdout?.on("data", onData);child.stderr?.on("data", onData);// Handle abort signal by killing the entire process tree.if (signal) { if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true });}// Handle shell spawn errors and wait for the process to terminate without hanging// on inherited stdio handles held by detached descendants.const exitCode = await waitForChildProcess(child);if (signal?.aborted) { throw new Error("aborted");}if (timedOut) { throw new Error(`timeout:${timeout}`);}// A signal-killed shell has no exit code. Use the standard shell convention so// callers do not mistake the termination for a successful command.const signalCode = child.signalCode;return { exitCode: exitCode ?? (signalCode ? 128 + (osConstants.signals[signalCode] ?? 0) : 1) };stdout và stderr đổ chung vào một onData, nên mô hình thấy chúng xen kẽ theo thứ tự Pi nhận được, như trong một terminal. Shell bị giết bằng tín hiệu thì không có mã thoát; dòng 158 đổi thành 128 cộng số hiệu tín hiệu theo quy ước của shell, để không bị hiểu nhầm là thành công. waitForChildProcess() giải quyết một ca khó: shell đã thoát nhưng một tiến trình con chạy nền vẫn giữ pipe output. Hàm không đóng pipe theo một hạn cố định mà chờ pipe im lặng 100ms, và mỗi chunk mới lại đặt lại đồng hồ (child-process.ts:39–48).
Khi hết giờ hoặc bị huỷ, Pi giết cả nhóm tiến trình chứ không chỉ shell, vì npm test có thể đã sinh ra cả chục tiến trình con:
/** * Kill a process and all its children (cross-platform) */export function killProcessTree(pid: number): void { if (process.platform === "win32") { } else { // Use SIGKILL on Unix/Linux/Mac try { process.kill(-pid, "SIGKILL"); } catch { // Fallback to killing just the child if process group kill fails try { process.kill(pid, "SIGKILL"); } catch { // Process already dead } } }process.kill(-pid) nghĩa là cả nhóm. Trên Windows, Pi gọi taskkill /F /T từ System32. Các PID tách riêng còn được ghi nhớ để giết nốt khi chính Pi thoát (shell.ts:196–215).Môi trường của lệnh
getShellEnv() sao chép môi trường của Pi và thêm ~/.pi/agent/bin (nơi Pi tải rg và fd về) vào đầu PATH (shell.ts:171–183). Mỗi lời gọi còn nhận vài biến PI_* mô tả session hiện tại, khớp với guideline “You can inspect PI_* environment variables” trong prompt:
const env = { ...getShellEnv() };delete env.PI_SESSION_ID;delete env.PI_SESSION_FILE;delete env.PI_PROVIDER;delete env.PI_MODEL;delete env.PI_REASONING_LEVEL;if (exposeSessionEnvironment && ctx) { const model = ctx.model; env.PI_SESSION_ID = ctx.sessionManager.getSessionId(); const sessionFile = ctx.sessionManager.getSessionFile(); if (sessionFile) env.PI_SESSION_FILE = sessionFile; if (model) { env.PI_PROVIDER = model.provider; env.PI_MODEL = model.id; } if (ctx.thinkingLevel) env.PI_REASONING_LEVEL = ctx.thinkingLevel;}const baseContext: BashSpawnContext = { command, cwd, env };return spawnHook ? spawnHook(baseContext) : baseContext;spawnHook cho extension sửa lệnh, thư mục hay môi trường trước khi chạy; setting shellCommandPrefix nối thêm một dòng vào trước mọi lệnh (bash.ts:268).Luồng output và file tạm
Lệnh chạy lâu thì giao diện cần thấy output ngay, còn bộ nhớ của Pi không được phình theo output. OutputAccumulator giải quyết cả hai: giải mã UTF-8 theo luồng, chỉ giữ trong bộ nhớ một đoạn đuôi cỡ hai lần giới hạn, và chuyển sang ghi file tạm ngay khi output vượt giới hạn:
append(data: Buffer): void { if (this.finished) { throw new Error("Cannot append to a finished output accumulator"); } this.totalRawBytes += data.length; this.appendDecodedText(this.decoder.decode(data, { stream: true })); if (this.tempFileStream || this.shouldUseTempFile()) { this.ensureTempFile(); this.tempFileStream?.write(data); } else if (data.length > 0) { this.rawChunks.push(data); }}private shouldUseTempFile(): boolean { return ( this.totalRawBytes > this.maxBytes || this.totalDecodedBytes > this.maxBytes || this.totalLines > this.maxLines );}private ensureTempFile(): void { if (this.tempFilePath) { return; } const { path, stream } = createOutputFileStream(this.tempFilePrefix, ".log"); this.tempFilePath = path; this.tempFileStream = stream; for (const chunk of this.rawChunks) { this.tempFileStream.write(chunk); } this.rawChunks = [];}pi-bash-<16 ký tự hex>.log, quyền 0600 vì output có thể chứa dữ liệu riêng (output-files.ts:14–20).Mỗi chunk đến thì tool đánh dấu “có dữ liệu mới” và hẹn một lần cập nhật, tối đa mười lần mỗi giây (BASH_UPDATE_THROTTLE_MS = 100, renderers/bash.ts:19). Mỗi lần cập nhật gửi qua onUpdate một ảnh chụp đã cắt đuôi; vòng lặp biến nó thành sự kiện tool_execution_update cho giao diện:
const scheduleOutputUpdate = () => { if (!onUpdate) return; updateDirty = true; const delay = BASH_UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt); if (delay <= 0) { clearUpdateTimer(); emitOutputUpdate(); return; } updateTimer ??= setTimeout(() => { updateTimer = undefined; emitOutputUpdate(); }, delay);};const handleData = (data: Buffer) => { if (!acceptingOutput) return; output.append(data); scheduleOutputUpdate();};Kết quả
} catch (err) { const snapshot = await finishOutput(); const { text } = formatOutput(snapshot, ""); if (err instanceof Error && err.message === "aborted") { throw new Error(appendStatus(text, "Command aborted")); } if (err instanceof Error && err.message.startsWith("timeout:")) { const timeoutSecs = err.message.split(":")[1]; throw new Error(appendStatus(text, `Command timed out after ${timeoutSecs} seconds`)); } throw err;}if (exitCode === null) { throw new Error(appendStatus(outputText, "Command terminated without an exit code"));}const wallTimeSeconds = Math.round((performance.now() - startedAt) / 100) / 10;const fullOutput = await output.readFullOutput(STRUCTURED_OUTPUT_MAX_BYTES);const structuredContent: BashToolOutput = { output: fullOutput.content, truncated: fullOutput.truncated, ...(fullOutput.truncated && snapshot.fullOutputPath ? { full_output_path: snapshot.fullOutputPath } : {}), exit_code: exitCode, wall_time_seconds: wallTimeSeconds,};if (exitCode !== 0) { return { content: [{ type: "text", text: appendStatus(outputText, `Command exited with code ${exitCode}`) }], details, structuredContent, isError: true, };}return { content: [{ type: "text", text: outputText }], details, structuredContent };Ba loại kết thúc được báo theo ba cách khác nhau. Mã thoát khác 0 là kết quả bình thường có isError: true: mô hình thấy output kèm dòng Command exited with code N, còn details (thông tin cắt bớt, đường dẫn file đầy đủ) vẫn tới giao diện. Huỷ và hết giờ thì throw một Error mà message đã chứa phần output thu được; vòng lặp agent bắt lỗi đó và biến thành kết quả lỗi (agent-loop.ts:855–863). structuredContent cho codemode có output tới 1MB (giữ đầu và đuôi), mã thoát và thời gian chạy.
powershell dùng đúng bộ máy này với pwsh.exe hoặc powershell.exe, chèn trước mỗi lệnh một câu đặt mã hoá output là UTF-8 (powershell.ts:16–37), và chỉ chạy trên Windows (shell.ts:144–147).
edit: thay thế đúng văn bản#
edit không nhận số dòng, cũng không nhận patch. Mô hình đưa đoạn văn bản cũ, chép nguyên văn, cùng đoạn thay thế; một lời gọi có thể chứa nhiều cặp như vậy cho cùng một file:
const replaceEditSchema = Type.Object( { oldText: Type.String({ description: "Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.", }), newText: Type.String({ description: "Replacement text for this targeted edit." }), }, {},);const editSchema = Type.Object( { path: Type.String({ description: "Path to the file to edit (relative or absolute)" }), edits: Type.Array(replaceEditSchema, { description: "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.", }), }, {},);export const editToolSystemPromptContribution = { snippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call", guidelines: [ "Use edit for precise changes (edits[].oldText must match exactly)", "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls", "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.", "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.", ],} as const;Quy tắc xuất hiện ở ba chỗ: mô tả từng trường (dòng 25, 37), mô tả tool (edit.ts:152), và guidelines trong system prompt. Changelog cho thấy schema này được chỉnh dần để giảm lời gọi sai: bản 0.63.0 cho phép nhiều edit trong một lời gọi (CHANGELOG.md:3048), bản 0.63.2 bỏ hẳn dạng một edit lẻ vì việc trộn hai dạng khiến mô hình gọi sai rồi thử lại liên tục (CHANGELOG.md:2987).
Sửa đối số trước khi kiểm tra schema
function prepareEditArguments(input: unknown): EditToolInput { if (!input || typeof input !== "object") { return input as EditToolInput; } const args = input as Record<string, unknown>; // Some models (Opus 4.6, GLM-5.1) send edits as a JSON string instead of an array. // Others send a single edit object instead of a one-element edits array. if (typeof args.edits === "string") { try { const parsed = JSON.parse(args.edits); if (Array.isArray(parsed)) { args.edits = parsed; } else if (isSingleEditInput(parsed)) { args.edits = [parsed]; } } catch {} } else if (isSingleEditInput(args.edits)) { args.edits = [args.edits]; } const legacy = args as LegacyEditToolInput; if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") { return args as EditToolInput; } const edits = Array.isArray(legacy.edits) ? [...legacy.edits] : []; edits.push({ oldText: legacy.oldText, newText: legacy.newText }); const { oldText: _oldText, newText: _newText, ...rest } = legacy; return { ...rest, edits } as EditToolInput;}Vòng lặp gọi prepareArguments ngay trước validateToolArguments() (agent-loop.ts:736–737). Hàm này đỡ ba dạng sai: edits là chuỗi JSON thay vì mảng (comment ghi tên Opus 4.6 và GLM-5.1), edits là một đối tượng đơn, và dạng cũ oldText/newText ở cấp cao nhất, vẫn còn trong các session cũ được mở lại (CHANGELOG.md:2972). Sửa ở đây rẻ hơn nhiều so với trả lỗi validation để mô hình gọi lại.
Thực thi
const { path, edits } = validateEditInput(input);const absolutePath = resolveToCwd(path, ctx?.cwd || cwd);return withFileMutationQueue(absolutePath, async () => { // Do not reject from an abort event listener here: that would release the // mutation queue while an in-flight filesystem operation may still finish. // Checking signal.aborted after each await observes the same aborts while // keeping the queue locked until the current operation has settled. const throwIfAborted = (): void => { if (signal?.aborted) throw new Error("Operation aborted"); }; // Read the file. const buffer = await ops.readFile(absolutePath); const rawContent = buffer.toString("utf-8"); throwIfAborted(); // Strip BOM before matching. The model will not include an invisible BOM in oldText. const { bom, text: content } = splitBom(rawContent); const originalEnding = detectLineEnding(content); const normalizedContent = normalizeToLF(content); const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path); throwIfAborted(); const finalContent = bom + restoreLineEndings(newContent, originalEnding); await ops.writeFile(absolutePath, finalContent); throwIfAborted(); const diffResult = generateDiffString(baseContent, newContent); const patch = generateUnifiedPatch(path, baseContent, newContent); return { content: [ { type: "text", text: `Successfully replaced ${edits.length} block(s) in ${path}.`, }, ], details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine }, };Mọi việc diễn ra trong hàng đợi của file (dòng 163): kiểm tra quyền đọc và ghi, đọc file, tách BOM (mô hình không bao giờ chép được một BOM vô hình vào oldText), ghi nhớ kiểu xuống dòng rồi chuẩn hoá về LF, áp các edit, khôi phục CRLF và BOM, ghi file. Nhờ vậy một file CRLF vẫn là CRLF sau khi sửa dù mô hình chỉ viết LF; kiểu xuống dòng được xác định theo lần xuống dòng đầu tiên trong file (edit-diff.ts:11–17). Comment ở dòng 164–167 giải thích vì sao huỷ được kiểm tra sau mỗi await thay vì reject từ listener: reject sớm sẽ nhả khoá hàng đợi trong khi một thao tác ghi file có thể vẫn đang chạy.
Khớp chính xác, rồi khớp mờ
Phần lõi là applyEditsToNormalizedContent(). Mọi edit được tìm trên cùng một nội dung gốc, không phải trên kết quả của edit trước, rồi được áp từ cuối file lên đầu để vị trí của các edit chưa áp không bị xê dịch:
const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch);const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;const matchedEdits: MatchedEdit[] = [];for (let i = 0; i < normalizedEdits.length; i++) { const edit = normalizedEdits[i]; const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText); if (!matchResult.found) { throw getNotFoundError(path, i, normalizedEdits.length); } const occurrences = countOccurrences(replacementBaseContent, edit.oldText); if (occurrences > 1) { throw getDuplicateError(path, i, normalizedEdits.length, occurrences); }matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);for (let i = 1; i < matchedEdits.length; i++) { const previous = matchedEdits[i - 1]; const current = matchedEdits[i]; if (previous.matchIndex + previous.matchLength > current.matchIndex) { throw new Error( `edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`, ); }}const baseContent = normalizedContent;const newContent = usedFuzzyMatch ? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits) : applyReplacements(replacementBaseContent, matchedEdits);if (baseContent === newContent) { throw getNoChangeError(path, normalizedEdits.length);}Quy tắc cụ thể:
- Chính xác trước.
fuzzyFindText()thửindexOfnguyên văn; chỉ khi không thấy mới chuẩn hoá cả hai phía rồi tìm lại (edit-diff.ts:207–245). - Một edit mờ thì cả lời gọi mờ. Nếu bất kỳ edit nào cần khớp mờ, mọi edit được tìm lại trên nội dung đã chuẩn hoá (dòng 318), để tất cả vị trí nằm trong cùng một hệ toạ độ.
- Phải duy nhất.
countOccurrences()luôn đếm trên nội dung đã chuẩn hoá (edit-diff.ts:247–251), kể cả khi khớp chính xác. - Không chồng lấn (dòng 345–348), và phải thay đổi được gì đó (dòng 357–358).
Chuẩn hoá mờ làm năm việc: NFKC, bỏ khoảng trắng cuối mỗi dòng, đổi dấu nháy cong thành nháy thẳng, đổi các loại gạch ngang Unicode thành -, đổi các khoảng trắng đặc biệt thành dấu cách (edit-diff.ts:34–55). Tính năng có từ 0.46.0 (CHANGELOG.md:4392). Khi khớp mờ, Pi không ghi cả file ở dạng đã chuẩn hoá: applyReplacementsPreservingUnchangedLines() chỉ viết lại những dòng bị edit chạm vào, mọi dòng khác được chép lại nguyên byte từ bản gốc (edit-diff.ts:122–136).
Các thông báo lỗi được viết để mô hình đọc và tự sửa: “Could not find the exact text in … The old text must match exactly including all whitespace and newlines.”, “Found 2 occurrences … Please provide more context to make it unique.”, hay “edits[0] and edits[1] overlap … Merge them into one edit” (edit-diff.ts:253–289).
Diff cho giao diện
Sau khi ghi, edit tạo hai biểu diễn của thay đổi, cả hai nằm trong details. generateDiffString() cho diff hiển thị: số dòng, bốn dòng ngữ cảnh mỗi phía, dấu ... thay cho đoạn dài không đổi, cùng firstChangedLine để editor nhảy tới (edit-diff.ts:372). generateUnifiedPatch() cho một patch unified chuẩn, dựng bằng thư viện diff (edit-diff.ts:365–370). Mô hình chỉ nhận một câu: Successfully replaced N block(s) in <path>.
write và hàng đợi ghi theo file#
write là tool đơn giản nhất: tạo thư mục cha nếu cần rồi ghi đè cả file.
const absolutePath = resolveToCwd(path, ctx?.cwd || cwd); const dir = dirname(absolutePath); return withFileMutationQueue(absolutePath, async () => { // Do not reject from an abort event listener here: that would release the // mutation queue while an in-flight filesystem operation may still finish. // Checking signal.aborted after each await observes the same aborts while // keeping the queue locked until the current operation has settled. const throwIfAborted = (): void => { if (signal?.aborted) throw new Error("Operation aborted"); }; throwIfAborted(); // Create parent directories if needed. await ops.mkdir(dir); throwIfAborted(); // Write the file contents. await ops.writeFile(absolutePath, content); throwIfAborted(); return { content: [{ type: "text", text: `Successfully wrote to ${path}` }], details: undefined, }; });},Guideline của nó, “Use write only for new files or complete rewrites.”, đẩy mô hình sang edit cho mọi thay đổi cục bộ: với write, mô hình phải sinh lại toàn bộ nội dung file trong đối số content.
Vì sao cần hàng đợi
Agent chạy batch tool song song theo mặc định (agent.ts:261), và không tool có sẵn nào đặt executionMode: "sequential". Mô hình hoàn toàn có thể phát hai lời gọi edit vào cùng một file trong một câu trả lời. Không có khoá, cả hai cùng đọc bản gốc, mỗi bên áp edit của mình, và bên ghi sau xoá mất thay đổi của bên ghi trước. withFileMutationQueue() tuần tự hoá thao tác trên cùng một file và vẫn để các file khác nhau chạy song song:
async function getMutationQueueKey(filePath: string): Promise<string> { const resolvedPath = resolve(filePath); try { return await realpath(resolvedPath); } catch (error) { if (isMissingPathError(error)) { return resolvedPath; } throw error; }}/** * Serialize file mutation operations targeting the same file. * Operations for different files still run in parallel. */export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> { const registration = registrationQueue.then(async () => { const key = await getMutationQueueKey(filePath); const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve(); let releaseNext!: () => void; const nextQueue = new Promise<void>((resolveQueue) => { releaseNext = resolveQueue; }); const chainedQueue = currentQueue.then(() => nextQueue); fileMutationQueues.set(key, chainedQueue); return { key, currentQueue, chainedQueue, releaseNext }; }); registrationQueue = registration.then( () => undefined, () => undefined, ); const { key, currentQueue, chainedQueue, releaseNext } = await registration; await currentQueue; try { return await fn(); } finally { releaseNext(); if (fileMutationQueues.get(key) === chainedQueue) { fileMutationQueues.delete(key); } }}Ba chi tiết làm hàng đợi đúng:
- Khoá là đường dẫn thật (
realpath, dòng 19):./a.ts,src/../a.tsvà một symlink trỏ tớia.tsrơi vào cùng một hàng. File chưa tồn tại thì dùng đường dẫn đã resolve. - Đăng ký cũng xếp hàng.
realpathlà bất đồng bộ, nên nếu mỗi lời gọi tự tính khoá, lời gọi đến sau có thể đăng ký trước.registrationQueuebuộc các lần đăng ký chạy lần lượt, nên thứ tự trong hàng đúng bằng thứ tự gọi. Vòng lặp khởi động các tool song song theo đúng thứ tự trong câu trả lời của mô hình (agent-loop.ts:656–658), nên edit đứng trước trong câu trả lời được áp trước. - Dọn dẹp. Xong việc thì nhả khoá cho người kế tiếp, và xoá mục trong map nếu không còn ai xếp sau (dòng 56–59), để map không lớn dần.
grep, find, ls: bộ tool chỉ đọc#
Ba tool này tắt theo mặc định. Chúng dành cho những cấu hình không có bash, như một agent chỉ được đọc, hoặc khi cần output có giới hạn và định dạng ổn định. Khi bash bật mà không tool nào trong ba tool này bật, system prompt thêm luật “Use bash for file operations like ls, rg, find” (system-prompt.ts:108–116).
grep chạy ripgrep. Nếu máy chưa có rg, ensureTool() tải bản phù hợp về ~/.pi/agent/bin, trừ khi đang ở chế độ offline (tools-manager.ts:350–380). Các cờ được cố định:
// --no-config: the tool contract (literal, glob, limit, ...) must not depend on// ambient user config like RIPGREP_CONFIG_PATH. Users can customize via bash or extensions.const args: string[] = ["--no-config", "--json", "--line-number", "--color=never", "--hidden"];if (ignoreCase) args.push("--ignore-case");if (literal) args.push("--fixed-strings");if (glob) args.push("--glob", glob);args.push("--", pattern, searchPath);--no-config để hợp đồng của tool (literal, glob, limit...) không phụ thuộc RIPGREP_CONFIG_PATH của người dùng; --hidden tìm cả dotfile nhưng vẫn tôn trọng .gitignore. Pi đọc các sự kiện match từ output --json, giết rg khi đủ 100 kết quả (mặc định), tự đọc file để lấy dòng ngữ cảnh khi có context, cắt mỗi dòng còn 500 ký tự, rồi cắt cả output theo 50KB. Mỗi giới hạn bị chạm sinh ra một thông báo hành động, ví dụ “100 matches limit reached. Use limit=200 for more, or refine pattern” (grep.ts:282–305).
find chạy fd theo cùng cách:
const args: string[] = ["--glob", "--color=never", "--hidden"];// fd normally ignores .gitignore outside git repos, so keep --no-require-git// there. Inside repos, use fd's default git-aware behavior so parent// .gitignore rules stop at nested repo boundaries:// https://github.com/earendil-works/pi/issues/5960let insideGitRepo = false;for (let current = searchPath; ; ) { if (await pathExists(path.join(current, ".git"))) { insideGitRepo = true; break; } const parent = path.dirname(current); if (parent === current) break; current = parent;}if (!insideGitRepo) args.push("--no-require-git");args.push("--max-results", String(effectiveLimit));// fd --glob matches against the basename unless --full-path is set; in --full-path// mode it matches against the absolute candidate path, so a path-containing// pattern like 'src/**/*.spec.ts' needs a leading '**/' to match anything.let effectivePattern = pattern;if (pattern.includes("/")) { args.push("--full-path"); if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") { effectivePattern = `**/${pattern}`; } // fd matches full paths using native separators on Windows. if (process.platform === "win32") effectivePattern = effectivePattern.replaceAll("/", String.raw`[/\\]`);}args.push("--", effectivePattern, searchPath);Hai chỗ đáng để ý. Ngoài một git repo, fd mặc định bỏ qua .gitignore, nên Pi thêm --no-require-git; trong repo thì dùng hành vi mặc định của fd để luật .gitignore của repo cha dừng ở ranh giới repo con. Và fd --glob chỉ so với tên file, nên một mẫu chứa / như src/**/*.spec.ts được chạy với --full-path và tiền tố **/. Mặc định tối đa 1.000 kết quả.
ls không gọi chương trình ngoài: đọc thư mục, sắp xếp không phân biệt hoa thường, thêm / sau thư mục, gồm cả dotfile, tối đa 500 mục (ls.ts:108–130). Như mọi tool có sẵn, cả ba nhận một interface *Operations để chạy trên máy khác; find còn cho thay hẳn fd bằng hàm glob() riêng.
Renderer: phần giao diện thấy#
Mỗi tool có sẵn trải renderCall và renderResult từ tools/renderers/ vào definition của mình (ví dụ edit.ts:214). Phần vẽ được tách khỏi phần chạy để một tiến trình chỉ hiển thị không phải nạp code thực thi và schema TypeBox, khoảng 17MB module (renderers/index.ts:1–7); withBuiltInRenderers() lắp renderer có sẵn vào definition nào chưa tự có (renderers/index.ts:51–63).
Một lời gọi tool có mặt trên màn hình từ trước khi chạy. Trong lúc assistant message còn stream, mỗi message_update có khối toolCall mới sẽ tạo một ToolExecutionComponent và cập nhật đối số của nó khi JSON dài dần (interactive-mode.ts:3486–3510). Sau đó tool_execution_start đánh dấu bắt đầu chạy, mỗi tool_execution_update vẽ lại với kết quả tạm, và tool_execution_end vẽ kết quả cuối cùng, gồm cả details:
case "tool_execution_update": { const component = this.pendingTools.get(event.toolCallId); if (component) { component.updateResult({ ...event.partialResult, isError: false }, true); this.ui.requestRender(); } break;}case "tool_execution_end": { if (event.isError) this.maybeShowInstallChangeWarning(); const component = this.pendingTools.get(event.toolCallId); if (component) { component.updateResult({ ...event.result, isError: event.isError, durationMs: event.durationMs }); this.pendingTools.delete(event.toolCallId); this.ui.requestRender(); } break;}Hai renderer đáng xem. Renderer của bash chỉ hiện năm dòng cuối, có phím mở rộng, và thay dòng thông báo “Full output: …” trong text bằng một dòng cảnh báo dựng từ details.truncation (renderers/bash.ts:39–87). Renderer của edit không chờ tool chạy xong: ngay khi đối số stream xong, nó tự tính diff xem trước trên file hiện tại, rồi thay bằng diff thật trong details khi kết quả về:
if (context.argsComplete && previewInput && !component.preview && !component.previewPending) { component.previewPending = true; const requestKey = argsKey; void computeEditsDiff(previewInput.path, previewInput.edits, context.cwd).then((preview) => { if (component.previewArgsKey === requestKey) { setEditPreview(component, preview, requestKey); context.invalidate(); } });}computeEditsDiff() chạy cùng thuật toán khớp với tool nhưng không ghi gì (edit-diff.ts:510–543), nên người dùng thấy diff, hay lỗi “không tìm thấy”, ngay khi mô hình vừa viết xong lời gọi.Lệnh ! của người dùng#
Người dùng gõ !git status trong editor thì lệnh không đi qua mô hình. AgentSession.executeBash() chạy nó bằng chính BashOperations mà tool bash dùng (nên extension nào thay backend cho tool cũng thay được cho lệnh !), qua một bộ thực thi riêng, bash-executor.ts (agent-session.ts:3901–3933). Khác biệt chính so với tool là output được làm sạch cho người đọc:
const appendText = (rawText: string) => { // Sanitize: strip ANSI, replace binary garbage, normalize newlines const text = sanitizeBinaryOutput(stripAnsi(rawText)).replace(/\r/g, ""); if (!text) { return; } // Start writing to temp file if exceeds threshold if (totalBytes > DEFAULT_MAX_BYTES) { ensureTempFile(); } if (tempFileStream) { tempFileStream.write(text); } // Keep rolling buffer outputChunks.push(text); outputBytes += text.length; while (outputBytes > maxOutputBytes && outputChunks.length > 1) { const removed = outputChunks.shift()!; outputBytes -= removed.length; } // Stream to callback if (options?.onChunk) { options.onChunk(text); }};Kết quả thành một BashExecutionMessage ghi vào session (agent-session.ts:3939–3960). Ở request kế tiếp, convertToLlm() đổi nó thành một tin nhắn user dạng “Ran `git status`” kèm output trong khối code (messages.ts:82–98). Lệnh !! chạy y hệt nhưng bị loại khỏi context (messages.ts:152–156). Thời điểm ghi, và vì sao message bị hoãn khi agent đang chạy, thuộc về chương AgentSession.
System prompt được dựng thế nào#
Toàn bộ việc dựng prompt nằm trong system-prompt.ts, 229 dòng, gần như toàn hàm thuần (chỉ đường dẫn tài liệu là lấy từ nơi cài đặt): đầu vào là một đối tượng tuỳ chọn, đầu ra là các section văn bản. Đầu vào trông như sau:
export interface BuildSystemPromptOptions { /** Custom system prompt (replaces the default prefix). */ customPrompt?: string; /** Exact full prompt replacement set by a before_agent_start handler. */ forceSystemPrompt?: string; /** Tools to include in prompt. Default: [read, bash, edit, write]. */ selectedTools?: string[]; /** * Selected tools whose declarations requests leave out (`prepareLoadout` hidden declarations). * They are reachable only through another tool, so the tool list and rules leave them out too. */ hiddenTools?: string[]; /** Optional one-line tool snippets keyed by tool name. */ toolSnippets?: Record<string, string>; /** Guideline bullets contributed by each tool, keyed by tool name. */ toolGuidelines?: Record<string, string[]>; /** Additional guideline bullets appended to the default system prompt rules. */ promptGuidelines?: string[]; /** Text appended from user configuration before project context, skills, and cwd. */ appendSystemPrompt?: string; /** Additional XML-wrapped prompt sections keyed by tag name. */ sections?: Record<string, string>; /** Working directory. */ cwd: string; /** Pre-loaded context files. */ contextFiles?: Array<{ path: string; content: string }>; /** Pre-loaded skills. */ skills?: Skill[];}AgentSession dựng đối tượng này trong _rebuildSystemPrompt(), xem bên dưới. Extension đọc và sửa được nó trong sự kiện before_agent_start (chương Extensions).Prompt không còn là một chuỗi dài mà là các section có tên. preamble là câu mở đầu không bọc thẻ; mọi section khác được bọc trong một thẻ cùng tên:
const declaredTools = selectedTools.filter((name) => !hiddenTools.includes(name));const promptSections: Record<string, string> = {};if (customPrompt) { promptSections.preamble = customPrompt;} else { promptSections.preamble = "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files."; const visibleTools = declaredTools.filter((name) => !!toolSnippets[name]); const tools = visibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets[name]}`).join("\n") : "(none)"; promptSections.tools = `${tools}\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.`; promptSections.rules = buildRules(declaredTools, toolGuidelines, promptGuidelines);if (appendSystemPrompt) promptSections.addendum = appendSystemPrompt;if (contextFiles.length > 0) promptSections.project_context = renderProjectContext(contextFiles);// A hidden reader is still reachable through another tool, so skills stay but the hint names no tool.const readers = ["read", "bash"] as const;const skillFileReadTool = readers.find((tool) => declaredTools.includes(tool)) ?? (readers.some((tool) => selectedTools.includes(tool)) ? "indirect" : undefined);if (skillFileReadTool && skills.length > 0) { const skillsPrompt = formatSkillsForPrompt(skills, skillFileReadTool).trim(); if (skillsPrompt) promptSections.skills = skillsPrompt;}promptSections.cwd = cwd.replace(/\\/g, "/");for (const [name, content] of Object.entries(customSections)) { if (content) promptSections[name] = content;}const sections: SystemPromptSections = { preamble: promptSections.preamble };for (const [name, content] of Object.entries(promptSections)) { if (name !== "preamble") sections[name] = `<${name}>\n${content}\n</${name}>`;}return sections;Thứ tự cố định: preamble, tools, rules, docs, addendum, project_context, skills, cwd, rồi các section tuỳ biến. Vài điểm đáng chú ý:
- Mục
toolschỉ liệt kê tool cópromptSnippet(dòng 157), kèm một câu nhắc rằng có thể còn tool khác. Mô hình vẫn thấy đủ tool qua phần khai báo tool của request; mục này chỉ là bản tóm tắt. customPromptthay cả bốn section đầu (dòng 152–153). Với--system-prompthaySYSTEM.md, mất luôn danh sách tool, luật và mục tài liệu, nhưng vẫn giữ addendum, AGENTS.md, skills và thư mục làm việc.- Mục
docschỉ đường tới README,docs/vàexamples/của gói Pi đã cài, và dặn chỉ đọc khi người dùng hỏi về chính Pi (system-prompt.ts:162–169). Nhờ mục này, khi được nhờ viết extension cho chính Pi, mô hình biết tài liệu nằm đâu để đọc; Armin Ronacher coi đây là một trong những điểm mạnh của Pi (Pi: The Minimal Agent Within OpenClaw). - Không có ngày giờ. Mục cuối cùng là thư mục làm việc, đổi dấu
\thành/(dòng 183). Ngày hiện tại đã bị bỏ khỏi prompt từ 0.80.7 vì nó làm prompt cache trượt mỗi khi qua ngày mới (CHANGELOG.md:1444).
Mục rules được ghép từ guidelines của các tool đang khai báo, theo thứ tự tool, bỏ trùng, rồi thêm hai luật chung:
function buildRules( selectedTools: string[], toolGuidelines: Record<string, string[]>, promptGuidelines: string[],): string { const rules: string[] = []; const seen = new Set<string>(); const addRule = (rule: string): void => { const normalized = rule.trim(); if (!normalized || seen.has(normalized)) return; seen.add(normalized); rules.push(normalized); }; const hasBash = selectedTools.includes("bash"); const hasPowerShell = selectedTools.includes("powershell"); const hasGrep = selectedTools.includes("grep"); const hasFind = selectedTools.includes("find"); const hasLs = selectedTools.includes("ls"); if ((hasBash || hasPowerShell) && !hasGrep && !hasFind && !hasLs) { if (hasBash && hasPowerShell) { addRule("Use bash or PowerShell for file operations like listing, searching, and finding files"); } else if (hasPowerShell) { addRule("Use PowerShell for file operations like listing, searching, and finding files"); } else { addRule("Use bash for file operations like ls, rg, find"); } } for (const name of selectedTools) { for (const rule of toolGuidelines[name] ?? []) addRule(rule); } for (const rule of promptGuidelines) addRule(rule); addRule("Be concise in your responses"); addRule("Show file paths clearly when working with files"); return rules.map((rule) => `- ${rule}`).join("\n");}grep/find/ls mới bảo mô hình dùng shell để tìm file. Changelog 0.77.0 ghi lại lý do: prompt cũ khuyên dùng các tool tìm file kể cả khi chúng không bật (CHANGELOG.md:1998).Prompt nằm trong transcript
Các section không chỉ được nối thành chuỗi. Chúng trở thành SystemMessage.sections, một message nằm ngay trong transcript:
export interface SystemMessage { role: "system"; /** Instruction text. On the leading message this is the base prompt; later, additional instructions. */ content: string | TextContent[]; /** * Named, ordered prompt sections rendered verbatim after `content`. The leading message * declares them; later messages replace sections by name, and `null` removes one. Keep * each section self-delimiting (a tag, a heading) so the model can relate an update to * the original. Avoid integer-like names; JSON objects reorder those. */ sections?: Record<string, string | null>; /** Complete definitions of tools that become available at this point. */ toolsAdded?: Tool[]; /** Tools that stop being available at this point. */ toolsRemoved?: ToolReference[]; timestamp: number; // Unix timestamp in milliseconds}Message system đầu tiên khai báo các section; các message system về sau chỉ thay section theo tên, null là xoá. getSystemMessageText() ghép content với các section bằng một dòng trống (pi-ai utils/text.ts:15–21). Khi prompt cần đổi giữa chừng (một tool vừa bật, một skill mới được nạp, AGENTS.md đổi sau /reload), AgentSession không viết lại message đầu tiên mà tính một bản vá:
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á được nối vào cuối transcript dưới dạng một message system mới (agent-session.ts:1758–1770). Lý do là prompt cache (chương Compaction có phần nói kỹ): cache khớp theo tiền tố chính xác, nên sửa message đầu tiên làm trượt cache của toàn bộ hội thoại, còn nối thêm ở cuối thì giữ nguyên tiền tố. Với mô hình hỗ trợ message system giữa hội thoại, provider nhận bản vá có tên section (utils/text.ts:28–40). Tính năng có từ 0.86.0 (CHANGELOG.md:609); cách nó đi qua session và resume thuộc chương AgentSession và chương Pi 1.0.
Còn một đường thứ ba: extension trả về systemPrompt trong before_agent_start sẽ đặt forceSystemPrompt, một chuỗi đóng kín thay toàn bộ prompt, không có section (system-prompt.ts:199–205).
Ai gom đầu vào
private _rebuildSystemPrompt(toolNames: string[]): void { const validToolNames = toolNames.filter((name) => this._toolRegistry.has(name)); const toolSnippets: Record<string, string> = {}; for (const name of this._toolRegistry.keys()) { const snippet = this._toolPromptSnippets.get(name); // Tools without a snippet are not listed. if (snippet) toolSnippets[name] = snippet; } const loaderSystemPrompt = this._resourceLoader.getSystemPrompt(); const loaderAppendSystemPrompt = this._resourceLoader.getAppendSystemPrompt(); const appendSystemPrompt = loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : ""; const loadedSkills = this._resourceLoader.getSkills().skills; const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles; this._baseSystemPromptOptions = normalizeBuildSystemPromptOptions({ cwd: this._cwd, skills: loadedSkills, contextFiles: loadedContextFiles, customPrompt: loaderSystemPrompt, appendSystemPrompt, selectedTools: validToolNames, hiddenTools: [...this._hiddenDeclarations], toolSnippets, toolGuidelines: Object.fromEntries(this._toolPromptGuidelines), });}Snippet và guideline lấy từ registry tool (đã gom ở _refreshToolRegistry()); mọi thứ khác lấy từ ResourceLoader, chủ đề của mục kế tiếp. Hàm chạy lại mỗi khi bộ tool đổi hay tài nguyên được nạp lại.
AGENTS.md, SYSTEM.md và skills#
Context file
Context file là file hướng dẫn của dự án mà Pi chèn vào prompt. Mỗi thư mục đóng góp tối đa một file, theo thứ tự ưu tiên cố định:
function loadContextFileFromDir(dir: string): { path: string; content: string } | null { const candidates = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]; for (const filename of candidates) { const filePath = join(dir, filename); if (existsSync(filePath)) { try { if (!statSync(filePath).isFile()) { continue; } return { path: filePath, content: stripBom(readFileSync(filePath, "utf-8")), }; } catch (error) { console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`)); } } } return null;}Thư mục nào có cả AGENTS.md lẫn CLAUDE.md thì chỉ AGENTS.md được nạp. Các thư mục được duyệt như sau:
const contextFiles: Array<{ path: string; content: string }> = [];const seenPaths = new Set<string>();const globalContext = loadContextFileFromDir(resolvedAgentDir);if (globalContext) { contextFiles.push(globalContext); seenPaths.add(globalContext.path);}let currentDir = resolvedCwd;while (true) { const contextFile = loadContextFileFromDir(currentDir); // Compare the directory's real path, not the file's: a worktree AGENTS.md that // symlinks to the main repo's file resolves to the shadowed path itself. const isShadowed = shadowedContextFile !== undefined && contextFile !== null && join(canonicalizePath(currentDir), basename(contextFile.path)) === shadowedContextFile; if (contextFile && !isShadowed && !seenPaths.has(contextFile.path)) { ancestorContextFiles.unshift(contextFile); seenPaths.add(contextFile.path); } const parentDir = dirname(currentDir); if (parentDir === currentDir) break; currentDir = parentDir;}contextFiles.push(...ancestorContextFiles);return contextFiles;File toàn cục trong ~/.pi/agent/ đứng đầu. Sau đó vòng lặp đi từ thư mục làm việc lên tới gốc hệ thống file; unshift (dòng 262) đảo thứ tự, nên trong prompt file của thư mục xa nhất đứng trước, file gần nhất đứng sau cùng. Đường dẫn đã thấy thì bỏ qua. Riêng trường hợp một git worktree nằm lồng bên trong repo chính, bản AGENTS.md của repo chính bị bỏ để không áp cùng một hướng dẫn hai lần (resource-loader.ts:205–230). Mỗi file được bọc trong <project_instructions path="..."> (system-prompt.ts:79–86). --no-context-files (-nc) tắt toàn bộ việc này (resource-loader.ts:646–653).
SYSTEM.md, APPEND_SYSTEM.md và cờ dòng lệnh
--system-prompt <văn bản> trở thành customPrompt, thay bốn section đầu như đã nói ở trên; --append-system-prompt thêm vào section addendum và dùng được nhiều lần (cli/args.ts:121–125). Giá trị của cả hai có thể là đường dẫn: nếu file tồn tại thì Pi đọc nội dung file, không thì dùng nguyên chuỗi (resource-loader.ts:167–182). Không có cờ thì Pi tìm file:
private discoverSystemPromptFile(): string | undefined { const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md"); if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) { return projectPath; } const globalPath = join(this.agentDir, "SYSTEM.md"); if (existsSync(globalPath)) { return globalPath; } return undefined;}private discoverAppendSystemPromptFile(): string | undefined { const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md"); if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) { return projectPath; } const globalPath = join(this.agentDir, "APPEND_SYSTEM.md"); if (existsSync(globalPath)) { return globalPath; } return undefined;}Khác với AGENTS.md, .pi/SYSTEM.md và .pi/APPEND_SYSTEM.md của dự án chỉ được dùng khi dự án đã được trust; bản trong ~/.pi/agent/ luôn được dùng. Cờ dòng lệnh thắng cả hai (resource-loader.ts:657–676).
Skills: chỉ tên và mô tả
Skill là một file SKILL.md chứa hướng dẫn cho một loại việc (cách tìm và nạp skill thuộc chương Extensions). Prompt không chứa nội dung skill, chỉ chứa danh mục:
export function formatSkillsForPrompt(skills: Skill[], fileReadTool: "read" | "bash" | "indirect" = "read"): string { const visibleSkills = skills.filter((s) => !s.disableModelInvocation); if (visibleSkills.length === 0) { return ""; } const lines = [ "\n\nThe following skills provide specialized instructions for specific tasks.", fileReadTool === "read" ? "Use the read tool to load a skill's file when the task matches its description." : fileReadTool === "bash" ? "Use bash to load a skill's file when the task matches its description." : "Load a skill's file when the task matches its description.", "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", "", "<available_skills>", ]; for (const skill of visibleSkills) { lines.push(" <skill>"); lines.push(` <name>${escapeXml(skill.name)}</name>`); lines.push(` <description>${escapeXml(skill.description)}</description>`); lines.push(` <location>${escapeXml(skill.filePath)}</location>`); lines.push(" </skill>"); } lines.push("</available_skills>"); return lines.join("\n");}disableModelInvocation không vào danh mục (dòng 359); người dùng vẫn gọi được nó bằng lệnh.Mô hình thấy tên, mô tả và đường dẫn của từng skill; khi việc đang làm khớp với mô tả, nó tự đọc SKILL.md bằng read (hoặc bash). Danh mục chỉ xuất hiện khi một trong hai tool đọc đó tồn tại (system-prompt.ts:174–182). Đây là cùng một kiểu nạp lười như bên tool search: chi phí prompt của một skill là vài chục token cho mô tả, còn hướng dẫn dài vài nghìn token chỉ tốn khi thật sự dùng.
Ví dụ minh hoạ: khung của system prompt mặc định, với một AGENTS.md và một skill
[preamble: một câu nói mô hình là trợ lý lập trình trong harness pi]
<tools>
- read: [promptSnippet của read]
- bash: [promptSnippet của bash]
- edit: [promptSnippet của edit]
- write: [promptSnippet của write]
[câu nhắc: có thể còn tool tuỳ biến khác]
</tools>
<rules>
- [luật dùng bash để tìm file, vì grep/find/ls đang tắt]
- [guidelines của read, bash, edit, write]
- [hai luật chung: ngắn gọn, ghi rõ đường dẫn]
</rules>
<docs>
[đường dẫn README, docs/, examples/ của gói pi đã cài]
</docs>
<project_context>
[một câu dẫn]
<project_instructions path="/home/me/work/app/AGENTS.md">
[nội dung AGENTS.md]
</project_instructions>
</project_context>
<skills>
[cách nạp skill]
<available_skills>
<skill>
<name>release</name>
<description>Cut a release of this repo</description>
<location>/home/me/work/app/.pi/skills/release/SKILL.md</location>
</skill>
</available_skills>
</skills>
<cwd>
/home/me/work/app
</cwd>
“Dưới 1.000 token”: điều gì còn đúng ở v1.4.0#
Bài blog tháng 11/2025 của Mario Zechner viết: “pi's system prompt and tool definitions together come in below 1000 tokens” (What I learned building an opinionated and minimal coding agent). Có thể đo lại ở v1.4.0 bằng chính các chuỗi trong source. Cách đo: ghép prompt mặc định (bốn tool, không AGENTS.md, không skill, cwd là /home/user/project, gói Pi cài ở một đường dẫn toàn cục dài 59 ký tự), tuần tự hoá khai báo bốn tool thành JSON kiểu Anthropic (name, description, input_schema), rồi đổi ký tự ra token bằng heuristic của chính Pi, số ký tự chia 4 (compaction.ts:294–298).
| Phần | Ký tự | ≈ token (ký tự/4) |
|---|---|---|
| preamble | 169 | 43 |
<tools> | 339 | 85 |
<rules> | 839 | 210 |
<docs> | 1.319 | 330 |
<cwd> | 31 | 8 |
| System prompt | 2.705 | 677 |
Khai báo read | 655 | 164 |
Khai báo bash | 514 | 129 |
Khai báo edit | 1.150 | 288 |
Khai báo write | 401 | 101 |
| Khai báo tool | 2.725 | 682 |
| Tổng | 5.430 | ≈ 1.360 |
Con số là ước lượng: tokenizer thật của mỗi provider đếm khác, và Pi tự gọi heuristic này là thận trọng, tức có xu hướng đếm dư. Nhưng kết luận đủ rõ: ở v1.4.0 prompt cùng tool mặc định không còn dưới 1.000 token, mà vào khoảng 1.200 đến 1.400. Điều vẫn đúng là bậc độ lớn: khoảng một nghìn token, không phải hàng chục nghìn. Phần tăng đến từ những thay đổi có lý do, đều có trong changelog:
editnhận nhiều edit (0.63.0, 0.63.2). Khai báo của nó giờ lớn nhất trong bốn tool, cộng thêm bốn guideline trong<rules>.- Mục
<docs>thành bản đồ tài liệu: liệt kê tài liệu theo chủ đề (extension, theme, skill, SDK, MCP, codemode...) để mô hình tự đọc khi được hỏi về Pi. Riêng mục này chiếm khoảng một phần tư tổng số. - Thẻ XML thay cho tiêu đề Markdown (CHANGELOG.md:2156, bản 0.75.0), để mô hình phân biệt ranh giới các phần rõ hơn.
- Một số thứ bị bỏ để giữ cache: giờ hiện tại bị bỏ ở 0.58.0 (CHANGELOG.md:3316), ngày hiện tại bị bỏ ở 0.80.7. Prompt nhỏ đi một chút, và quan trọng hơn là không còn đổi theo thời gian.
Những gì giữ nguyên: bốn tool mặc định, grep/find/ls vẫn tắt, phần mở đầu vẫn là một câu, và AGENTS.md vẫn được nối vào sau prompt gốc. Trong một dự án thật, AGENTS.md và danh mục skill thường lớn hơn cả prompt gốc lẫn bốn khai báo tool cộng lại.
Trace: mô hình gọi edit#
Người dùng nhờ: “cho server đọc cổng từ biến PORT”. Mô hình đã read file src/server.ts ở lượt trước, giờ trả lời bằng một lời gọi tool:
Ví dụ minh hoạ: khối toolCall trong assistant message
{
"type": "toolCall",
"id": "toolu_01",
"name": "edit",
"arguments": {
"path": "src/server.ts",
"edits": [
{ "oldText": "const port = 3000;",
"newText": "const port = Number(process.env.PORT ?? 3000);" }
]
}
}- Stream đối sốpi-ai ghép JSON của
argumentstừ từng mảnh khi stream (chương pi-ai). Giao diện tạo component cho lời gọi ngay từ mảnh đầu; khi đối số đủ, renderer củaedittính diff xem trước trên file thật. Chưa có gì được ghi. - Vào batchAssistant message kết thúc. Vòng lặp lấy mọi khối
toolCall, chọn chế độ song song (không tool nào đòi tuần tự) và pháttool_execution_start(agent-loop.ts:606–614). - Chuẩn bịTìm tool theo tên, chạy
prepareArguments, kiểm tra schema, gọi hookbeforeToolCall. Mỗi bước hỏng đều thành một kết quả lỗi “ngay lập tức” mà tool không hề chạy (đoạn code bên dưới). - Thực thi
executePreparedToolCall()gọiAgentTool.execute(); lớp bọc thêmctxrồi gọiToolDefinition.execute(). Thời gian chạy đo bằng đồng hồ đơn điệu; exception bị bắt và thành kết quả lỗi (agent-loop.ts:831–867). - Bên trong editĐăng ký vào hàng đợi của
src/server.ts, kiểm tra quyền, đọc, tách BOM, chuẩn hoá LF.indexOf("const port = 3000;")thấy đúng một chỗ, đếm trùng ra 1, không chồng lấn. Áp edit, khôi phục kiểu xuống dòng, ghi file, tạo diff và patch. - Sau khi chạy
afterToolCallphát sự kiệntool_resultcho extension, bên đó có thể thaycontent,detailshayisError(agent-loop.ts:880–907; agent-session.ts:684). - Báo kết quả
tool_execution_endmang toàn bộAgentToolResult; giao diện thay diff xem trước bằngdetails.diffthật. RồicreateToolResultMessage()dựngToolResultMessage, phátmessage_start/message_end. - Ghi session
AgentSessionnhậnmessage_endvà ghi message thành mộtSessionMessageEntry, giữ nguyêndetails(agent-session.ts:1158–1166). - Request kế tiếpContext được dựng lại từ session; adapter của provider biến message thành khối
tool_resultchỉ cócontentvàis_error. Mô hình đọc đúng một câu: “Successfully replaced 1 block(s) in src/server.ts.”
Bước 3 là nơi phần lớn lỗi dừng lại:
const tool = tools.find((t) => t.name === toolCall.name);if (!tool) { return { kind: "immediate", result: createErrorToolResult(`Tool ${toolCall.name} not found`), isError: true, };}try { const preparedToolCall = prepareToolCallArguments(tool, toolCall); const validatedArgs = validateToolArguments(tool, preparedToolCall); if (beforeResult?.block) { const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked"); if (beforeResult.terminate === true) { result.terminate = true; } return { kind: "immediate", result, isError: true, }; }} catch (error) { return { kind: "immediate", result: createErrorToolResult(error instanceof Error ? error.message : String(error)), isError: true, };}Lỗi schema do validateToolArguments() của pi-ai tạo. Hàm sao chép đối số, ép kiểu theo schema (chuỗi "5" thành số 5 cho timeout), kiểm tra, và nếu sai thì liệt kê từng lỗi kèm nguyên văn đối số đã nhận, để mô hình thấy chính xác mình gửi gì (pi-ai validation.ts:317–353).
Giả sử mô hình viết oldText là port thay vì cả dòng. Bước 5 khi đó throw: “Found 3 occurrences of the text in src/server.ts. The text must be unique. Please provide more context to make it unique.” Bước 4 bắt exception và trả một kết quả lỗi với đúng câu đó; còn giao diện thì đã hiện lỗi này trong khung lời gọi ngay từ bước 1, nhờ diff xem trước. Mô hình đọc lỗi và gọi lại với oldText dài hơn. Không có code “thử lại” nào trong harness: vòng lặp chỉ cần trả lỗi về đúng dạng.
| Ai | Nhận gì từ lời gọi này |
|---|---|
| Mô hình | Một dòng text trong content và cờ lỗi |
| Giao diện | Đối số đang stream, diff xem trước, details.diff, isError, durationMs |
| File session | ToolResultMessage đầy đủ: content, details (diff, patch, firstChangedLine), isError, durationMs |
| Extension | tool_call với đối số đã kiểm tra; tool_result với content, details, isError |
Quyết định thiết kế#
Bốn tool là đủ
Bài blog năm 2025 nói thẳng: “these four tools are all you need for an effective coding agent”, vì “models know how to use bash and have been trained on the read, write, and edit tools with similar input schemas” (Mario Zechner). Mọi việc khác (tìm kiếm, chạy test, git, xem process) đi qua bash với những chương trình mô hình đã quen. Mỗi tool thêm vào có hai cái giá: vài trăm token khai báo trong mọi request, và một chỗ có thể làm trượt prompt cache, vì khai báo tool nằm gần đầu prompt nên đổi bộ tool là đổi tiền tố (Prompt Caching In Agents). grep, find, ls vẫn có cho cấu hình không có shell, nhưng tắt theo mặc định.
Cũng bài đó có câu “Bash is all you need”, viết khi giải thích vì sao Pi không có bash chạy nền: muốn một tiến trình sống lâu thì dùng tmux. Ở v1.4.0 vẫn vậy: tool bash không có chế độ nền, không có timeout mặc định, và mỗi lời gọi chờ tiến trình kết thúc.
Không có hệ thống quyền
Pi không hỏi xác nhận trước mỗi lời gọi tool. Tài liệu bảo mật mở đầu bằng câu: Pi “can read, change, and execute files with the permissions of the account that started it, and it does not ask for approval before every tool call” (docs/security.md:3). Lý do là hộp thoại xác nhận không tạo ra ranh giới an toàn: an toàn đến từ việc giới hạn file, credential, tiến trình và mạng mà Pi chạm được, còn việc xem transcript hay review thay đổi thì không phải ranh giới (docs/security.md:7). Bài blog nói gắt hơn: các biện pháp bảo mật của agent khác “mostly security theater”, và “as soon as your agent can write code and run code, it's pretty much game over”.
Thay cho hộp thoại, tài liệu đưa ra các mức cô lập: chạy cả Pi trong container, Docker Sandboxes hay OpenShell, hoặc giữ Pi trên máy và chỉ đẩy tool vào một micro-VM Gondolin (docs/containerization.md:9–14). Phương án cuối dùng đúng các interface *Operations ở trên: extension Gondolin thay read, write, edit, bash, grep, find, ls bằng bản chạy trong VM (docs/containerization.md:181). Ai vẫn muốn hỏi xác nhận thì viết extension bắt sự kiện tool_call; repo có sẵn ví dụ permission-gate.ts (docs/extensions.md:390). Bộ --tools read,grep,find,ls thu hẹp khả năng, không phải sandbox: read vẫn đọc được mọi file mà tài khoản của bạn đọc được.
Kết quả hai phần, thông báo hành động
Tách content khỏi details là quyết định đã có từ thời bài blog 2025 (“splitting tool results into a portion handed to the LLM and a portion for UI display”). Nó cho phép edit trả mô hình một câu trong khi người dùng xem diff đầy đủ, và cho bash báo thông tin cắt bớt cho giao diện mà không tốn token. Cùng tinh thần đó, mọi giới hạn đều đi kèm một câu nói bước tiếp theo (offset=…, limit=…, “Full output: …”): harness không tóm tắt hay đoán hộ, nó để mô hình tự phân trang bằng chính các tool sẵn có.
Backend thay được
Mỗi tool nhận một interface *Operations với comment “Override these to delegate … to remote systems (for example SSH)” (ví dụ read.ts:44–55). Logic của tool (cắt bớt, khớp văn bản, thông báo lỗi) giữ nguyên; chỉ chỗ chạm vào hệ thống file và tiến trình được thay. Đây là cách SSH, container hay micro-VM được cắm vào mà không phải viết lại tool.
Cạm bẫy và trường hợp biên#
- Lệnh không bao giờ tự dừng.
bashkhông có timeout mặc định, nênnpm run devgiữ lượt hiện tại cho tới khi người dùng huỷ. Chạy nền bằng&cũng chưa chắc thoát được: theo comment củawaitForChildProcess(), một tiến trình con vẫn đang ghi vào pipe kế thừa sẽ giữ Pi tiếp tục đọc. Hãy chuyển output của tiến trình nền ra file (cmd > log 2>&1 &) hoặc dùngtmux. - Huỷ là SIGKILL. Hết giờ hay bị huỷ thì cả nhóm tiến trình nhận
SIGKILL, không có cơ hội dọn dẹp. Tiến trình nào tự tách khỏi nhóm (daemon dùngsetsid) thì thoát khỏi lệnh giết này. - Đường dẫn không bị giới hạn. Đường dẫn tuyệt đối,
~và..đều dùng được; thư mục làm việc chỉ là gốc cho đường dẫn tương đối (docs/security.md:19). Bộ tool chỉ đọc vẫn đọc được~/.ssh. - bash không vào hàng đợi. Hàng đợi ghi chỉ bọc
editvàwrite. Một lời gọibashchạysed -isong song với mộtedittrên cùng file vẫn có thể giẫm lên nhau. - Khớp mờ đổi cả dòng. Khi phải khớp mờ, các dòng bị edit chạm vào được viết lại ở dạng đã chuẩn hoá: mất khoảng trắng cuối dòng, nháy cong thành nháy thẳng. Ngược lại, đếm trùng luôn chạy trên dạng chuẩn hoá, nên một đoạn duy nhất khi so nguyên văn vẫn có thể bị báo trùng nếu sau chuẩn hoá nó giống một đoạn khác.
- Mô hình chỉ thấy đuôi. Với một build log dài, lỗi đầu tiên thường nằm ở đầu, mà
bashchỉ giữ 2.000 dòng cuối. Mô hình phải tựreadfile đầy đủ theo đường dẫn trong thông báo; file nằm trong thư mục tạm của hệ điều hành. - Thứ bạn thấy khác thứ mô hình thấy. Renderer bỏ mã màu ANSI và ký tự nhị phân trước khi vẽ (render-utils.ts:67), còn
contentgửi cho mô hình là output thô. Lệnh ép màu (FORCE_COLOR=1,--color=always) sẽ đổ escape code vào context dù màn hình trông sạch. Lệnh!của người dùng thì được làm sạch trước khi vào context. --system-promptbỏ nhiều hơn bạn nghĩ. Prompt tuỳ biến thay cả mục tool, luật và tài liệu. Khai báo tool vẫn được gửi, nhưng các guideline như “edits[].oldText must match exactly” thì mất, trừ khi bạn tự viết lại.- Ảnh và mô hình không đọc ảnh. Với mô hình không nhận ảnh,
readvẫn trả khối ảnh nhưng ghi chú rằng ảnh sẽ bị bỏ khỏi request; ảnh bị thu nhỏ thì toạ độ mô hình nói ra là toạ độ trên ảnh nhỏ, phải nhân với hệ số trong ghi chú. - Context file của repo lạ. AGENTS.md và CLAUDE.md nạp không cần trust, như đã nói ở trên. Đó là một đường prompt injection có sẵn.
Đọc code ở đâu#
Đường dẫn tính từ packages/coding-agent/, trừ khi ghi khác. Thứ tự gợi ý: từ định nghĩa, qua các tool, tới system prompt.
Tóm tắt#
- Tám tool có sẵn, bốn tool bật mặc định:
read,bash,edit,write. Mọi việc khác đi quabash. - Mỗi tool là một
ToolDefinitioncó ba mặt: khai báo cho mô hình, đóng góp cho system prompt, renderer cho giao diện. Kết quả táchcontent(cho mô hình) khỏidetails(cho giao diện và session). - Output bị cắt ở 2.000 dòng hoặc 50KB, kèm câu hướng dẫn lấy tiếp;
bashgiữ đuôi và lưu toàn bộ ra file tạm. editthay đúng văn bản: khớp nguyên văn trước, khớp mờ sau, bắt buộc duy nhất và không chồng lấn, giữ BOM và CRLF. Hàng đợi theo đường dẫn thật ngăn các lần ghi song song đè nhau.- System prompt là các section có tên, nằm trong transcript; đổi giữa chừng thì nối bản vá chứ không viết lại, để giữ prompt cache. Không còn ngày giờ trong prompt.
- AGENTS.md/CLAUDE.md nạp từ thư mục toàn cục và mọi thư mục cha, không cần trust; skills chỉ góp tên, mô tả và vị trí.
- Prompt và bốn khai báo tool mặc định giờ khoảng 1.300 token (ước lượng ký tự/4), không còn dưới 1.000 như năm 2025.
- Không có hệ thống quyền: an toàn đến từ cô lập ở tầng hệ điều hành; muốn hỏi xác nhận thì viết extension.
10Compaction và tóm tắt nhánh#
Vai trò#
Mỗi lượt (turn) làm transcript dài thêm: tin nhắn người dùng, câu trả lời của mô hình, và nhất là kết quả tool. Cửa sổ ngữ cảnh (context window) của mô hình thì cố định, ví dụ 200.000 token. Một buổi làm việc thật, đọc vài chục file và chạy test nhiều lần, chạm giới hạn đó sau vài giờ; khi đó provider từ chối request với lỗi tràn ngữ cảnh. Compaction (nén ngữ cảnh) thay phần cũ của hội thoại bằng một bản tóm tắt có cấu trúc, giữ nguyên văn phần gần nhất, rồi để session chạy tiếp.
Ba lớp tham gia:
/compact. Gọi hook extension, ghi kết quả, phát sự kiện.CompactionEntry vào file JSONL và áp nó khi dựng lại context (chương Session).Cùng thư mục còn có tóm tắt nhánh (branch summarization): khi rời một nhánh bằng /tree, Pi có thể tóm tắt nhánh bị bỏ lại và mang bản tóm tắt sang nhánh mới. Hai cơ chế dùng chung cách tuần tự hoá hội thoại, chung system prompt tóm tắt và chung cách theo dõi file.
Một chi tiết lịch sử: bài blog ngày 30/11/2025 của Mario Zechner viết “Missing compaction hasn't been a problem for me personally”; compaction xuất hiện bốn ngày sau, ở bản 0.12.7 (CHANGELOG.md:6131), với hai con số mặc định vẫn dùng tới hôm nay: giữ 20K token gần nhất, chừa 16K token (nay là 16.384). Pi Durable có cơ chế nén riêng (chương Durable: task); chương này chỉ nói về coding agent.
Mô hình tư duy#
Nhánh đang hoạt động trước khi nén (mỗi ô là một entry, từ gốc tới lá)
┌────┬────┬────┬─ ··· ─┬─────┬─────┬─────┬─────┬─ ··· ─┬─────┐
│ u1 │ a2 │ t3 │ │ u30 │ a31 │ t32 │ a33 │ │ t42 │
└────┴────┴────┴─ ··· ─┴─────┴─────┴─────┴─────┴─ ··· ─┴─────┘
└─── messagesToSummarize ───┘└ turn prefix ┘└── giữ lại, ~20K ──┘
↑ firstKeptEntryId
Sau khi nén: thêm một entry compaction ở cuối, không xoá gì khỏi file
┌────┬─ ··· ─┬─────┬─ ··· ─┬─────┬─────┐
│ u1 │ │ a33 │ │ t42 │ cmp │
└────┴─ ··· ─┴─────┴─ ··· ─┴─────┴─────┘
Context của request kế tiếp
┌──────────────────┬──────────────────┬─────┬─ ··· ─┬─────┐
│ system (bản chụp) │ tóm tắt (vai user)│ a33 │ │ t42 │ + entry mới
└──────────────────┴──────────────────┴─────┴─ ··· ─┴─────┘
u: user, a: assistant, t: kết quả tool, cmp: CompactionEntry
- Ngưỡng (threshold)
contextWindow - reserveTokens. Context vượt ngưỡng thì nén.- reserveTokens
- Khoảng trống chừa lại, mặc định 16.384. Từ 1.4.0 nó chỉ quyết định khi nào bắt đầu nén.
- keepRecentTokens
- Ngân sách cho phần giữ nguyên văn, mặc định 20.000 token, “roughly 5 to 20 turns” theo bài How Compaction Works in Pi.
- Điểm cắt (cut point), firstKeptEntryId
- Entry đầu tiên được giữ. Mọi thứ trước nó (tính từ ranh giới của lần nén trước) được tóm tắt.
- Lượt bị tách (split turn), turn prefix
- Khi điểm cắt rơi vào giữa một lượt, phần đầu của lượt đó (từ tin nhắn user tới trước điểm cắt) được tóm tắt riêng.
- Bản tóm tắt trước (previousSummary)
- Lần nén sau không tóm tắt lại từ đầu mà cập nhật bản tóm tắt của lần trước.
- Phép chiếu (projection)
- Danh sách message mà
buildSessionProjection()dựng từ nhánh đang hoạt động, đã áp compaction và context edit (chương Session).
Data model#
Ba con số cấu hình, kèm giá trị mặc định:
export interface CompactionSettings { enabled: boolean; reserveTokens: number; keepRecentTokens: number;}export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000,};Trong settings.json chúng nằm dưới khoá compaction; hai giá trị token còn có thể đặt riêng cho từng mô hình qua compaction.modelOverrides với khoá "provider/modelId". Mỗi giá trị được tra theo thứ tự: override của mô hình, setting thường, mặc định (settings-manager.ts:833–880; docs/compaction.md:439–463). enabled là setting toàn cục; tắt nó chỉ tắt nén tự động, /compact vẫn chạy.
Kết quả của một lần nén được ghi thành một entry trong session:
export interface CompactionEntry<T = unknown> extends SessionEntryBase { type: "compaction"; summary: string; firstKeptEntryId: string; tokensBefore: number; /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ details?: T; /** Usage from the LLM call(s) that generated this summary, if available */ usage?: Usage; /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */ fromHook?: boolean; /** Complete prompt and tool state at this compaction boundary. */ systemMessage?: SystemMessage;}summarylà văn bản thuần, đọc được, chuyển được sang provider khác.firstKeptEntryIdtrỏ vào entry đầu tiên được giữ nguyên văn. Nén kiểu “không giữ gì” (retain-none) ghi chính id của entry compaction (CHANGELOG.md:551).tokensBeforelà ước lượng kích thước context ngay trước khi nén.detailscủa bản nén mặc định là danh sách file đã đọc và đã sửa (CompactionDetails, compaction.ts:51–55); extension tự nén thì để gì vào đây cũng được.fromHook: bản tóm tắt do extension tạo. Tên cũ, giữ để tương thích file session.systemMessage: bản chụp prompt và bộ tool hiện hành tại ranh giới nén. Trường này không có trong khốiCompactionEntrycủadocs/compaction.md(docs/compaction.md:144–156); code ở v1.4.0 có nó.
Ví dụ minh hoạ: một dòng compaction trong file JSONL (rút gọn, xuống dòng cho dễ đọc)
{"type":"compaction","id":"7f3a9c21","parentId":"c41e0b77",
"timestamp":"2026-10-22T05:12:40.114Z",
"summary":"## Goal\n- Thêm cache cho API /users\n...\n\n<read-files>\nsrc/api.ts\n</read-files>\n\n<modified-files>\nsrc/cache.ts\n</modified-files>",
"firstKeptEntryId":"a33d10e5","tokensBefore":190350,
"details":{"readFiles":["src/api.ts"],"modifiedFiles":["src/cache.ts"]},
"usage":{"input":39120,"output":1650, ...},
"systemMessage":{"role":"system","content":"","sections":{...},"toolsAdded":[...],"timestamp":1761109960114}}Hai kiểu trung gian chảy giữa prepareCompaction() và compact(). CompactionPreparation là thứ extension nhận được trong hook session_before_compact:
export interface CompactionPreparation { /** UUID of first entry to keep */ firstKeptEntryId: string; /** Messages that will be summarized and discarded */ messagesToSummarize: AgentMessage[]; /** Messages that will be turned into turn prefix summary (if splitting) */ turnPrefixMessages: AgentMessage[]; /** Whether this is a split turn (cut point in middle of turn) */ isSplitTurn: boolean; tokensBefore: number; /** Summary from previous compaction, for iterative update */ previousSummary?: string; /** File operations extracted from messagesToSummarize */ fileOps: FileOperations; /** Compaction settions from settings.jsonl */ settings: CompactionSettings;}settings.json./** Result from compact() - SessionManager adds uuid/parentUuid when saving */export interface CompactionResult<T = unknown> { summary: string; firstKeptEntryId: string; tokensBefore: number; estimatedTokensAfter?: number; /** Usage from the LLM call(s) that generated this summary, if available */ usage?: Usage; /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ details?: T;}Khi dựng context, entry compaction trở thành một CompactionSummaryMessage (vai compactionSummary, mang summary và tokensBefore, messages.ts:62–67), rồi convertToLlm() đổi nó thành một tin nhắn user trước khi gửi đi; mục ghi entry và dựng lại context đi chi tiết.
Khi nào compaction chạy#
Điều kiện tự động chỉ có một dòng:
/** * Check if compaction should trigger based on context usage. */export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean { if (!settings.enabled) return false; return contextTokens > contextWindow - settings.reserveTokens;}Với mô hình 200.000 token và reserveTokens mặc định, Pi nén khi context vượt 183.616 token. Từ 1.4.0, reserveTokens chỉ quyết định thời điểm bắt đầu; request tóm tắt dùng giới hạn output của chính mô hình (CHANGELOG.md:33), và tham số reserveTokens của generateSummary() chỉ còn để giữ tương thích (compaction.ts:647).
Câu hỏi khó hơn là kiểm tra ở đâu. AgentSession kiểm tra ở bốn chỗ, cộng thêm lệnh thủ công:
- Trước một prompt mới
_prepareRun()gọi_checkCompaction(lastAssistant, false)trước khi gửi tin nhắn của người dùng. Tham sốfalsecho phép xét cả phản hồi bị huỷ: người dùng bấm Esc khi context đã gần đầy thì prompt kế tiếp vẫn được nén trước (agent-session.ts:1926–1932). - Giữa hai lượtBatch tool xong, kết quả đã nối vào transcript, mô hình chưa được gọi lại: hook
prepareNextTurnWithContextgọi_compactBeforeNextAssistantResponse()(agent-session.ts:904–905). Điểm kiểm tra này có từ 0.84.4, để một kết quả tool khổng lồ không bị gửi đi trước khi kịp nén (CHANGELOG.md:779). - Sau khi lần chạy kết thúc
_handlePostAgentRun()gọi_checkCompaction()với assistant message cuối; kết quảtrue(cần thử lại, hoặc còn tin nhắn chờ) khiến vòng ngoài gọiagent.continue()(agent-session.ts:2008–2010; vòng ngoài ở :1899–1905). - Khi tràn ngữ cảnhCũng trong
_checkCompaction(): lỗi tràn từ provider, tràn “im lặng” (provider trả thành công nhưng input vượt cửa sổ), hay phản hồi dừng vìlengthsớm hơn giới hạn output. Pi bỏ lần thử hỏng khỏi context, nén, rồi thử lại đúng một lần. - Thủ công
/compact [hướng dẫn]gọiAgentSession.compact()(interactive-mode.ts:7053–7057); hướng dẫn được nối vào prompt tóm tắt dưới dạng “Additional focus”.
Giữa hai lượt
private async _compactBeforeNextAssistantResponse(context: AgentContext): Promise<AgentContext> { const projection = this.sessionManager.buildSessionProjection(); // A virtual selection is checked in prepareRequest, against the model the request is routed to. const model = this.model; if (!model || isVirtualModel(model) || !this._exceedsCompactionThreshold(model, projection)) { return { ...context, messages: projection.messages }; } await this._runAutoCompaction("threshold", false); return { ...context, messages: this.sessionManager.buildSessionProjection().messages };}estimateProjectedContextTokens() trên phép chiếu hiện tại (agent-session.ts:767–774): usage của assistant message cuối cộng ước lượng các kết quả tool vừa thêm. Mô hình ảo (virtual model) được kiểm tra muộn hơn, trong prepareRequest, với mô hình thật mà request được định tuyến tới (:838–841).Không có điểm kiểm tra nào cắt ngang một câu trả lời đang stream. Bản 0.17.0 từng có “proactive compaction”, huỷ lượt giữa chừng khi sắp chạm ngưỡng, và đã bỏ nó (CHANGELOG.md:5969). Các điểm kiểm tra hiện nay đều nằm ở ranh giới tự nhiên: trước prompt, giữa hai lượt, sau lần chạy.
Sau lần chạy: _checkCompaction()
private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<boolean> { const settings = this.settingsManager.getCompactionSettings(this.model); if (!settings.enabled) return false; // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false; // Skip compaction checks if this assistant message is older than the latest // compaction boundary. This prevents a stale pre-compaction usage/error // from retriggering compaction on the first prompt after compaction. const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch()); const assistantIsFromBeforeCompaction = compactionEntry !== null && assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime(); if (assistantIsFromBeforeCompaction) { return false; } const explicitOverflow = assistantMessage.stopReason === "error" && isContextOverflow(assistantMessage); const contextOverflow = sameModel && ((explicitOverflow && assistantRetainedForExplicitRecovery) || (assistantUsageMatchesProjection && isContextOverflow(assistantMessage, contextWindow))); const recoverableLength = sameModel && assistantIsProjected && isRecoverableLength(assistantMessage, messageModel.maxTokens); if (contextOverflow || recoverableLength) { const willRetry = assistantMessage.stopReason !== "stop"; // Case 2: the response completed successfully. Compact, but do not retry because // agent.continue() cannot continue from a completed assistant response. if (!willRetry) { return await this._runAutoCompaction("overflow", false); // Persistently omit the selected final attempt before post-run recovery compaction. this._overflowRecoveryAttempted = true; this._omitRecoveryAttempt(assistantMessage); const retry = await this._runAutoCompaction("overflow", willRetry); if (retry) this._failedResponse = assistantMessage; return retry;Đọc theo thứ tự:
- Các điều kiện loại trừ. Tắt nén tự động thì thôi. Phản hồi bị huỷ thì bỏ qua, trừ lần kiểm tra trước prompt. Phản hồi đến từ mô hình khác mô hình hiện tại thì không xét tràn (agent-session.ts:3018–3025): lỗi tràn của một mô hình 200K không có nghĩa gì sau khi đã đổi sang mô hình 1M. Và assistant message cũ hơn entry compaction mới nhất thì bỏ qua, vì usage của nó phản ánh context trước lần nén, dùng lại sẽ nén ngay lần nữa (dòng 3031–3034).
- Nhận diện tràn.
isContextOverflow()của pi-ai khớp thông báo lỗi với một bộ mẫu theo provider, cộng hai kiểu tràn không báo lỗi: phản hồi thành công màinput + cacheReadvượt cửa sổ, và phản hồilengthvới output bằng 0 khi input đã lấp đầy cửa sổ (pi-ai overflow.ts:142–176).isRecoverableLength()nhận phản hồilengthdừng dưới giới hạn output gốc của mô hình (:184–186). - Tràn mà phản hồi đã xong (
stopReasonlà"stop"): nén nhưng không thử lại, vì không thể “tiếp tục” một câu trả lời đã hoàn chỉnh (dòng 3075–3076). - Tràn thật: chỉ một lần thử. Pi đánh dấu đã thử, ghi
context_editloại lần thử hỏng cùng các kết quả tool của nó khỏi context (agent-session.ts:1239–1272), nén với lý do"overflow", rồi trảtrueđể vòng ngoài gọi lại mô hình. Lần thứ hai vẫn tràn thì dừng với thông báo “Context overflow recovery failed after one compact-and-retry attempt…” (:3079–3082). Cờ “đã thử” được xoá khi có tin nhắn user mới hoặc một phản hồi thành công (:1123, :1173–1174).
Không tràn thì đến nhánh ngưỡng. Phần khó là lấy được một con số đáng tin:
let contextTokens: number;const projection = currentProjection;const hasContextEdits = projection.entries.some((entry) => entry.sourceEntry.type === "context_edit");const directContextTokens = assistantMessage.usage ? calculateContextTokens(assistantMessage.usage) : 0;if (hasContextEdits) { contextTokens = estimateProjectedContextTokens(projection, branch).tokens;} else if (assistantMessage.stopReason === "error" || directContextTokens === 0) { const messages = this.agent.state.messages; const estimate = estimateContextTokens(messages); contextTokens = estimate.tokens;} else { contextTokens = directContextTokens;}if (shouldCompact(contextTokens, contextWindow, settings)) { return await this._runAutoCompaction("threshold", false);}return false;Có context edit trên nhánh thì usage cũ không còn khớp với thứ sẽ gửi đi, nên Pi ước lượng lại từ phép chiếu. Phản hồi lỗi (ví dụ 529 quá tải) hay usage toàn số 0 thì lấy usage hợp lệ gần nhất cộng ước lượng phần sau nó, và bỏ qua nếu usage đó có trước lần nén cuối (dòng 3122–3136). Còn lại thì dùng thẳng usage provider báo cho phản hồi vừa xong. Mục ước lượng token đi vào các hàm này.
Nén thủ công
async compact(customInstructions?: string): Promise<CompactionResult> { await this.abort(); this._compactionAbortController = new AbortController(); this._emit({ type: "compaction_start", reason: "manual" }); let fromExtension = false; let cancelledByExtension = false; try { const model = this.model; if (!model) { throw new Error(formatNoModelSelectedMessage()); } const settings = this.settingsManager.getCompactionSettings(model); const pathEntries = this.sessionManager.getBranch(); const preparation = prepareCompaction(pathEntries, settings); if (!preparation) { // Check why we can't compact const lastEntry = pathEntries[pathEntries.length - 1]; if (lastEntry?.type === "compaction") { throw new Error("Already compacted"); } throw new Error("Nothing to compact (session too small)"); }Sau bước chuẩn bị, nén thủ công và nén tự động (_runAutoCompaction()) đi chung một đường: phát compaction_start, hỏi hook session_before_compact (có thể huỷ hoặc trả bản tóm tắt riêng), nếu không thì chạy bộ tóm tắt mặc định, ghi entry, dựng lại context, phát session_compact cho extension và compaction_end cho giao diện (agent-session.ts:3157–3271). Nén tự động xong mà còn tin nhắn steer hay follow-up đang chờ thì trả true để lần chạy tiếp tục giao chúng (dòng 3269–3271).
Ước lượng token#
Pi không chạy tokenizer. Mọi ước lượng dùng một heuristic: số ký tự chia 4.
const ESTIMATED_IMAGE_CHARS = 4800;export function estimateTokens(message: AgentMessage): number { let chars = 0; switch (message.role) { case "system": { const system = message as SystemMessage; chars = estimateTextAndImageContentChars(system.content); if (system.sections) { for (const section of Object.values(system.sections)) { if (section) chars += section.length; } } if (system.toolsAdded) chars += JSON.stringify(system.toolsAdded).length; return Math.ceil(chars / 4); } case "user": { chars = estimateTextAndImageContentChars( (message as { content: string | Array<{ type: string; text?: string }> }).content, ); return Math.ceil(chars / 4); } case "assistant": { const assistant = message as AssistantMessage; for (const block of assistant.content) { if (block.type === "text") { chars += block.text.length; } else if (block.type === "thinking") { chars += block.thinking.length; } else if (block.type === "toolCall") { chars += block.name.length + JSON.stringify(block.arguments).length; } } return Math.ceil(chars / 4); } case "custom": case "toolResult": { chars = estimateTextAndImageContentChars(message.content); return Math.ceil(chars / 4); }Ước lượng thô chỉ dùng cho phần chưa có số liệu thật. Mỗi assistant message mang usage do provider báo, và usage đó đo chính xác cái request vừa gửi: input, output, cache đọc, cache ghi (compaction.ts:140–142). Phản hồi bị huỷ, bị lỗi hay có usage toàn số 0 bị bỏ qua (:148–161). Kích thước context vì thế được tính bằng usage hợp lệ cuối cùng cộng ước lượng của mọi message đến sau nó:
export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate { const usageInfo = getLastAssistantUsageInfo(messages); if (!usageInfo) { let estimated = 0; for (const message of messages) { estimated += estimateTokens(message); } return { tokens: estimated, usageTokens: 0, trailingTokens: estimated, lastUsageIndex: null, }; } const usageTokens = calculateContextTokens(usageInfo.usage); let trailingTokens = 0; for (let i = usageInfo.index + 1; i < messages.length; i++) { trailingTokens += estimateTokens(messages[i]); } return { tokens: usageTokens + trailingTokens, usageTokens, trailingTokens, lastUsageIndex: usageInfo.index, };}Sau một kết quả tool 24.000 ký tự, con số là usage của lượt trước cộng 6.000. Phần “đuôi” thường nhỏ, nên sai số của heuristic ít ảnh hưởng. estimateProjectedContextTokens() thêm một lớp an toàn: nếu usage cuối cùng đến từ một entry nằm trước context edit hay compaction mới nhất, usage đó đo một context đã không còn, nên hàm bỏ nó và ước lượng toàn bộ phép chiếu bằng ký tự/4 (compaction.ts:226–262). Hàm này cho ra tokensBefore của mỗi lần nén.
Chọn điểm cắt#
Điểm cắt không được rơi vào bất kỳ đâu. Một kết quả tool phải đi ngay sau lời gọi tool sinh ra nó; provider từ chối một tool_result không có tool_use đi trước. Vì vậy chỉ một số loại message được làm điểm cắt:
function isCutPointMessage(message: AgentMessage): boolean { switch (message.role) { case "user": case "assistant": case "bashExecution": case "custom": case "branchSummary": case "compactionSummary": return true; case "toolResult": return false; } return false;}isTurnStartMessage(), nhận diện message mở đầu một lượt: user, lệnh bash của người dùng, custom message và hai loại tóm tắt (:366–379).Thuật toán đi ngược từ entry mới nhất, cộng dồn ước lượng token, dừng khi chạm keepRecentTokens:
const cutPoints: number[] = [];for (let i = startIndex; i < endIndex; i++) { const entry = entries[i]; if (entry.sourceEntry.type !== "compaction" && entry.messages.some(isCutPointMessage)) cutPoints.push(i);}if (cutPoints.length === 0) { return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };}let accumulatedTokens = 0;let exceededBudget = false;let cutIndex = cutPoints[0];for (let i = endIndex - 1; i >= startIndex; i--) { const messageTokens = entries[i].messages.reduce((sum, message) => sum + estimateTokens(message), 0); if (messageTokens === 0) continue; accumulatedTokens += messageTokens; if (accumulatedTokens >= keepRecentTokens) { exceededBudget = true; cutIndex = cutPoints.find((candidate) => candidate >= i) ?? cutPoints[cutPoints.length - 1]; break; }}while (cutIndex > startIndex) { const previous = entries[cutIndex - 1]; if (previous.sourceEntry.type === "compaction" || previous.messages.length > 0) break; cutIndex--;}const startsTurn = isProjectedTurnStart(entries[cutIndex]);const turnStartIndex = startsTurn ? -1 : findProjectedTurnStartIndex(entries, cutIndex, startIndex);return { firstKeptEntryIndex: cutIndex, turnStartIndex, isSplitTurn: !startsTurn && turnStartIndex !== -1,};Ba chi tiết:
- Tiến về phía mới, không lùi về phía cũ. Entry làm tổng vượt ngân sách thường là một kết quả tool, không cắt được. Pi chọn điểm cắt hợp lệ đầu tiên tại hoặc sau entry đó (dòng 813), nên phần giữ lại xoay quanh 20.000 token, và thường hụt một chút khi entry làm vượt ngân sách là một kết quả tool. Nếu chỉ riêng mấy kết quả tool cuối đã vượt ngân sách, điểm cắt là điểm hợp lệ cuối cùng, tức assistant message gọi các tool đó.
- Kéo theo metadata. Các entry không sinh message (đổi mô hình, nhãn...) đứng ngay trước điểm cắt được giữ cùng (dòng 845–849), dừng ở ranh giới compaction.
- Lượt bị tách. Nếu entry ở điểm cắt không mở đầu một lượt, tức là một assistant message giữa lượt, Pi tìm ngược về tin nhắn user mở đầu lượt đó (dòng 850–851). Một lượt dài (một yêu cầu dẫn tới vài chục lời gọi tool) một mình có thể lớn hơn 20.000 token, nên trường hợp này không hiếm.
Đoạn bị lược (dòng 818–843) xử lý một trường hợp hẹp của phục hồi sau tràn: nếu phía sau điểm cắt chỉ còn những lần thử đã bị loại khỏi context, điểm cắt được đẩy qua chúng để phần input chưa gửi vẫn được giữ nguyên văn. Hàm cũ findCutPoint(), làm việc trên entry thô thay vì phép chiếu, vẫn được export công khai (src/index.ts:41), nhưng Pi không còn gọi nó.
Chuẩn bị: prepareCompaction()
if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") { return undefined;}const prevCompactionIndex = projectedEntries.findIndex( (entry) => entry.sourceEntry.type === "compaction" && entry.messages.length > 0,);let previousSummary: string | undefined;let boundaryStart = 0;if (prevCompactionIndex >= 0) { previousSummary = (projectedEntries[prevCompactionIndex].sourceEntry as CompactionEntry).summary; // The canonical projection has already selected the previous compaction's retained tail. boundaryStart = prevCompactionIndex + 1;}const boundaryEnd = projectedEntries.length;const tokensBefore = estimateProjectedContextTokens(projection, pathEntries).tokens;const cutPoint = findProjectedCutPoint(projectedEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);const firstKeptEntry = projectedEntries[cutPoint.firstKeptEntryIndex]?.sourceEntry;if (!firstKeptEntry?.id) return undefined;const firstKeptEntryId = firstKeptEntry.id;const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;const messagesToSummarize = projectedEntries .slice(boundaryStart, historyEnd) .flatMap(getMessagesFromProjectedEntryForCompaction);const turnPrefixMessages = cutPoint.isSplitTurn ? projectedEntries .slice(cutPoint.turnStartIndex, cutPoint.firstKeptEntryIndex) .flatMap(getMessagesFromProjectedEntryForCompaction) : [];if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) return undefined;- Entry cuối đã là compaction thì không có gì để làm (dòng 863).
- Ranh giới của lần nén trước. Trong phép chiếu, compaction mới nhất đứng ở vị trí 0, theo sau là phần nó đã giữ lại. Việc tóm tắt lần này bắt đầu ngay sau nó (dòng 878–882), nên những message từng được giữ nguyên văn ở lần trước sẽ được tóm tắt ở lần này, cùng với bản tóm tắt cũ trong vai
previousSummary(docs/compaction.md:83). - Hai danh sách. Không tách lượt:
messagesToSummarizelà mọi thứ từ ranh giới tới điểm cắt. Tách lượt: danh sách đó dừng ở đầu lượt, phần đầu lượt vàoturnPrefixMessages. Message system và entry compaction không vào danh sách nào (:98–102), vì bản chụp prompt đi theo entry compaction. - Không có gì để tóm tắt thì trả
undefined(dòng 901), thay vì sinh ra một bản tóm tắt rỗng.
Prompt tóm tắt và định dạng bản tóm tắt#
Bản tóm tắt được tạo bằng một request riêng, không phải bằng cách nhờ chính cuộc hội thoại tự tóm tắt mình. Request đó có system prompt riêng:
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;và chỉ một tin nhắn user. Hội thoại cần tóm tắt được tuần tự hoá thành văn bản thay vì gửi dưới dạng message, để mô hình không coi đó là một cuộc trò chuyện cần đáp tiếp:
export function serializeConversation(messages: Message[]): string { const parts: string[] = []; for (const msg of messages) { if (msg.role === "user") { const content = contentText(msg.content, ""); if (content) parts.push(`[User]: ${content}`); } else if (msg.role === "assistant") { const toolCalls: string[] = []; for (const block of msg.content) { if (block.type === "toolCall") { const args = block.arguments as Record<string, unknown>; const argsStr = Object.entries(args) .map(([k, v]) => `${k}=${JSON.stringify(v)}`) .join(", "); toolCalls.push(`${block.name}(${argsStr})`); } } if (msg.content.some((block) => block.type === "text")) { parts.push(`[Assistant]: ${contentText(msg.content)}`); } if (toolCalls.length > 0) { parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`); } } else if (msg.role === "toolResult") { const content = contentText(msg.content, ""); if (content) { parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`); } } } return parts.join("\n\n");}TOOL_RESULT_MAX_CHARS, dòng 94), vì kết quả tool là phần lớn nhất của context mà bản tóm tắt không cần nguyên văn. Thinking bị bỏ hẳn từ 1.2.0 (CHANGELOG.md:135): thinking được phát lại thường không còn nằm trong context thật của mô hình, và có thể làm request tóm tắt lớn hơn chính context nó tóm tắt.Prompt cho lần nén đầu tiên yêu cầu một định dạng cố định:
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.Use this EXACT format:## Goal[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]## Constraints & Preferences- [Any constraints, preferences, or requirements mentioned by user]- [Or "(none)" if none were mentioned]## Progress### Done- [x] [Completed tasks/changes]### In Progress- [ ] [Current work]### Blocked- [Issues preventing progress, if any]## Key Decisions- **[Decision]**: [Brief rationale]## Next Steps1. [Ordered list of what should happen next]## Critical Context- [Any data, examples, or references needed to continue]- [Or "(none)" if not applicable]Keep each section concise. Preserve exact file paths, function names, and error messages.`;Định dạng này là một checkpoint cho một mô hình khác đọc (“another LLM will use to continue the work”): mục tiêu, ràng buộc của người dùng, tiến độ chia ba trạng thái, các quyết định kèm lý do, bước tiếp theo, và dữ liệu cần thiết. Câu cuối, giữ nguyên đường dẫn, tên hàm và thông báo lỗi, nhắm vào đúng những thứ mà một bản tóm tắt tự do hay làm mất. Từ lần nén thứ hai, Pi gửi kèm bản tóm tắt cũ và đổi sang prompt cập nhật:
const UPDATE_SUMMARIZATION_INSTRUCTIONS = `Update the existing structured summary with new information. RULES:- PRESERVE all existing information from the previous summary- ADD new progress, decisions, and context from the new messages- UPDATE the Progress section: move items from "In Progress" to "Done" when completed- UPDATE "Next Steps" based on what was accomplished- PRESERVE exact file paths, function names, and error messages- If something is no longer relevant, you may remove itconst UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.${UPDATE_SUMMARIZATION_INSTRUCTIONS}`;Hàm ghép tất cả lại:
// Use update prompt if we have a previous summary, otherwise initial promptlet basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;if (customInstructions) { basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;}// Serialize conversation to text so model doesn't try to continue it// Convert to LLM messages first (handles custom types like bashExecution, custom, etc.)const llmMessages = convertToLlm(currentMessages);const conversationText = serializeConversation(llmMessages);// Build the prompt with conversation wrapped in tagslet promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;if (previousSummary) { promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;}promptText += basePrompt;const failure = getSummarizationFailure(response, "Summarization");if (failure) { throw new Error(failure);}if (response.content.some((block) => block.type === "toolCall")) { throw new Error("Summarization attempted to call a tool");}const textContent = contentText(response.content);return { text: textContent, usage: response.usage };Thứ tự trong tin nhắn là hội thoại trong thẻ <conversation>, bản tóm tắt cũ trong <previous-summary>, rồi mới đến chỉ dẫn; hướng dẫn của /compact được nối cuối với tiền tố “Additional focus:”. Kết quả bị từ chối trong ba trường hợp: lỗi, dừng vì chạm giới hạn output (một bản tóm tắt dở dang không được thành checkpoint, compaction.ts:585–593), và mô hình cố gọi tool.
Mọi request tóm tắt, của compaction lẫn của tóm tắt nhánh, đi qua một chỗ:
// Avoid cache writes for one-off summaries. Reuse caller-supplied routing when available;// callers without a session ID, including branch summaries, receive a fresh routing ID.const requestOptions: SimpleStreamOptions = { ...options, cacheRetention: "none", sessionId: options.sessionId ?? uuidv7(),};const produce = async (): Promise<AssistantMessage> => streamFn ? (await streamFn(model, context, requestOptions)).result() : completeSimple(model, context, requestOptions);return retryAssistantCall(produce, retry, requestOptions.signal, callbacks);cacheRetention: "none" vì một prompt tóm tắt dùng một lần sẽ không bao giờ được tái sử dụng; ghi cache cho nó chỉ tốn thêm tiền. sessionId là một id định tuyến mới cho mỗi request, trừ khi caller truyền vào; cả hai thay đổi này có từ bản sửa ở 0.82.0 (CHANGELOG.md:1260).Mô hình tóm tắt là mô hình hiện tại của session, cùng mức suy nghĩ (thinking level) và cùng hàm stream (agent-session.ts:2783–2806; :572–609). Bài How Compaction Works in Pi nhấn mạnh rằng vì tóm tắt là một request riêng nên nó có thể dùng một mô hình khác, rẻ hơn; ở v1.4.0, muốn vậy thì viết extension, như ví dụ ở mục extension.
Lượt bị tách: hai bản tóm tắt
Khi điểm cắt rơi giữa một lượt, phần đầu lượt được tóm tắt bằng một prompt khác, ngắn hơn và nói rõ phần sau đã được lưu riêng:
const TURN_PREFIX_SUMMARIZATION_PROMPT = `The messages above are earlier context from an ongoing conversation. Later messages are stored separately and do not need to be reconstructed.Create a concise checkpoint of the user's request and the progress shown above. This checkpoint will be placed before the later messages so the conversation can continue with the necessary context.## Original Request[What did the user ask for?]## Progress So Far- [Key decisions and work completed in these messages]## Context Needed to Continue- [Information from these messages needed to understand the later work]Only summarize information explicitly present above. Do not infer or recreate later messages.`;# Conversation và # Instructions (compaction.ts:1078).compact() gọi hai request lần lượt, không song song, để provider cục bộ chỉ phục vụ một request một lúc không trả lỗi 429 (CHANGELOG.md:1517), rồi ghép hai kết quả và nối danh sách file:
// Merge into single summary summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`; summaryUsage = historyUsage ? combineUsage(historyUsage, turnPrefixResult.usage) : turnPrefixResult.usage;// Compute file lists and append to summaryconst { readFiles, modifiedFiles } = computeFileLists(fileOps);summary += formatFileOperations(readFiles, modifiedFiles);if (!firstKeptEntryId) { throw new Error("First kept entry has no UUID - session may need migration");}return { summary, firstKeptEntryId, tokensBefore, usage: summaryUsage, details: { readFiles, modifiedFiles } as CompactionDetails,};Không có lịch sử nào trước lượt bị tách (ví dụ cả session chỉ có một yêu cầu rất dài) thì phần lịch sử là bản tóm tắt cũ, hoặc chuỗi “No prior history.” (compaction.ts:980–983).
Theo dõi file đã đọc và đã sửa#
Bản tóm tắt do mô hình viết có thể bỏ sót một đường dẫn. Danh sách file thì không cần mô hình: Pi tự rút nó ra từ các lời gọi tool và nối vào cuối bản tóm tắt.
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void { if (message.role === "toolResult") { // Calls made from codemode scripts are recorded on the script's result. for (const call of message.nestedCalls?.calls ?? []) addFileOp(call.name, call.arguments, fileOps); return; } if (message.role !== "assistant") return; if (!("content" in message) || !Array.isArray(message.content)) return; for (const block of message.content) { if (typeof block !== "object" || block === null) continue; if (!("type" in block) || block.type !== "toolCall") continue; if (!("arguments" in block) || !("name" in block)) continue; addFileOp(block.name, block.arguments as Record<string, unknown> | undefined, fileOps); }}function addFileOp(toolName: string, args: Record<string, unknown> | undefined, fileOps: FileOperations): void { const path = typeof args?.path === "string" ? args.path : undefined; if (!path) return; switch (toolName) { case "read": fileOps.read.add(path); break; case "write": fileOps.written.add(path); break; case "edit": fileOps.edited.add(path); break; }}nestedCalls), nên hàm đọc cả chỗ đó (dòng 31–34).export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } { const modified = new Set([...fileOps.edited, ...fileOps.written]); const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort(); const modifiedFiles = [...modified].sort(); return { readFiles: readOnly, modifiedFiles };}/** * Format file operations as XML tags for summary. */export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string { const sections: string[] = []; if (readFiles.length > 0) { sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`); } if (modifiedFiles.length > 0) { sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`); } if (sections.length === 0) return ""; return `\n\n${sections.join("\n\n")}`;}File vừa đọc vừa sửa chỉ nằm trong danh sách “đã sửa”. Cả hai danh sách được sắp xếp, đặt trong thẻ <read-files> và <modified-files>, rồi ghi thêm vào details của entry. Lần nén sau gộp danh sách của lần trước vào (compaction.ts:60–88), nên sau mười lần nén, danh sách vẫn phủ cả session. Ngoại lệ là bản nén do extension tạo (fromHook): Pi không biết details của nó có dạng gì nên không đọc (dòng 70).
Giới hạn rõ ràng của cách này: chỉ ba tool read, write, edit được tính, theo đối số path. File sửa bằng sed -i hay git checkout qua bash, hoặc file chỉ được tìm thấy bằng grep, không xuất hiện trong danh sách.
Ghi entry và dựng lại context#
Có bản tóm tắt rồi, AgentSession gọi appendCompaction():
/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */appendCompaction<T = unknown>( summary: string, firstKeptEntryId: string | null, tokensBefore: number, details?: T, fromHook?: boolean, usage?: Usage,): string { const timestamp = new Date().toISOString(); const systemMessage = getCurrentSystemMessage(this.buildSessionProjection().messages); const id = generateId(this.byId); const entry: CompactionEntry<T> = { type: "compaction", id, parentId: this.leafId, timestamp, summary, firstKeptEntryId: firstKeptEntryId ?? id, tokensBefore, details, usage, fromHook, ...(systemMessage ? { systemMessage: { ...systemMessage, timestamp: new Date(timestamp).getTime() } } : {}), }; this._appendEntry(entry); return entry.id;}Entry được ghi thêm như mọi entry khác: con của lá hiện tại, rồi trở thành lá mới. Không entry nào bị xoá hay sửa. Dòng 1270 chụp lại prompt và bộ tool hiện hành: getCurrentSystemMessage() của pi-ai phát lại mọi message system của phép chiếu (message đầu cộng các bản vá) thành một message duy nhất chứa đủ section và tool (pi-ai transcript.ts:68–95). Bản chụp cần thiết vì sau khi nén, message system đầu tiên của session nằm trong phần bị bỏ.
Lần dựng context kế tiếp áp entry này. buildContextEntries() chọn entry nào được chiếu thành message:
export function buildContextEntries( entries: SessionEntry[], leafId?: string | null, byId?: Map<string, SessionEntry>,): SessionEntry[] { const path = buildSessionPath(entries, leafId, byId); let compaction: CompactionEntry | null = null; for (const entry of path) { if (entry.type === "compaction") { compaction = entry; } } if (!compaction) { return path; } const compactionIdx = path.findIndex((entry) => entry.id === compaction.id); if (compactionIdx < 0) { return path; } const contextEntries: SessionEntry[] = [compaction]; let foundFirstKept = false; for (let i = 0; i < compactionIdx; i++) { const entry = path[i]; if (entry.id === compaction.firstKeptEntryId) { foundFirstKept = true; } if (foundFirstKept && !(entry.type === "message" && entry.message.role === "system")) { contextEntries.push(entry); } } contextEntries.push(...path.slice(compactionIdx + 1)); return contextEntries;}Thứ tự kết quả: entry compaction mới nhất đứng đầu; rồi các entry từ firstKeptEntryId tới trước entry compaction, trừ message system (bản chụp đã thay chúng); rồi mọi entry sau compaction. Bản thân entry compaction chiếu thành hai message, bản chụp prompt và bản tóm tắt (session-manager.ts:461–464); một compaction cũ hơn lỡ nằm trong phần được giữ thì không đóng góp gì (:558–564). Trước khi gửi, convertToLlm() bọc bản tóm tắt thành một tin nhắn user với lời dẫn cố định (messages.ts:176–183):
export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:<summary>`;export const COMPACTION_SUMMARY_SUFFIX = `</summary>`;export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from:<summary>`;export const BRANCH_SUMMARY_SUFFIX = `</summary>`;Ngay sau khi ghi, _refreshFinalizedContext() đặt agent.state.messages bằng phép chiếu mới (agent-session.ts:938–944), và estimatedTokensAfter được tính trên phép chiếu đó cho sự kiện compaction_end. Phần lịch sử bị tóm tắt vẫn nằm trong file, và /tree vẫn quay về được một entry trước lần nén. Cách phép chiếu xử lý nhánh, context edit và các loại entry khác thuộc chương Session.
Extension tuỳ biến hoặc thay compaction#
Trước mỗi lần nén, thủ công hay tự động, extension nhận sự kiện sau:
/** Fired before context compaction (can be cancelled or customized) */export interface SessionBeforeCompactEvent { type: "session_before_compact"; preparation: CompactionPreparation; branchEntries: SessionEntry[]; customInstructions?: string; /** What triggered the compaction: manual /compact, the context threshold, or context overflow recovery */ reason: "manual" | "threshold" | "overflow"; /** True when the aborted turn is retried after this compaction (overflow recovery) */ willRetry: boolean; signal: AbortSignal;}{ cancel: true } để huỷ, hoặc { compaction: CompactionResult } để thay bộ tóm tắt mặc định (types.ts:1511–1514; xử lý ở agent-session.ts:3184–3204).Extension nhận đúng CompactionPreparation mà bộ mặc định sẽ dùng, nên có thể giữ nguyên điểm cắt và chỉ thay cách tóm tắt. Ví dụ đi kèm repo tóm tắt bằng Gemini Flash:
pi.on("session_before_compact", async (event, ctx) => { ctx.ui.notify("Custom compaction extension triggered", "info"); const { preparation, branchEntries: _, signal } = event; const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, previousSummary } = preparation; // Use Gemini Flash for summarization (cheaper/faster than most conversation models) const model = ctx.modelRegistry.find("google", "gemini-2.5-flash"); if (!model) { ctx.ui.notify(`Could not find Gemini Flash model, using default compaction`, "warning"); return; } // Combine all messages for full summary const allMessages = [...messagesToSummarize, ...turnPrefixMessages]; // Return compaction content - SessionManager adds id/parentId // Use firstKeptEntryId from preparation to keep recent messages return { compaction: { summary, firstKeptEntryId, tokensBefore, usage: response.usage, }, };undefined và Pi dùng bộ mặc định.Comment đầu file nói extension này “discards all old turns completely, keeping only the summary”, nhưng code trả về firstKeptEntryId của bản chuẩn bị (dòng 105), nên phần ~20.000 token gần nhất vẫn được giữ như bình thường. Khác biệt thật nằm ở chỗ khác: nó tóm tắt cả turnPrefixMessages trong cùng một request (dòng 35) và dùng prompt riêng. Muốn bỏ hết phần giữ lại thì phải trả một firstKeptEntryId khác, chẳng hạn kiểu retain-none.
Sau khi nén, extension nhận session_compact với entry đã lưu; nén thất bại hay bị huỷ thì nhận session_compact_failed (types.ts:797–821). Hướng dẫn đầy đủ cho người viết extension ở docs/compaction.md:293–383; API extension nói chung ở chương Extensions.
Tóm tắt nhánh khi rời nhánh qua /tree#
Session của Pi là một cây (chương Session). /tree cho người dùng nhảy về một entry cũ và đi tiếp theo hướng khác, ví dụ bỏ một hướng sửa lỗi không ra gì. Nhưng những gì học được trên nhánh bị bỏ (file nào liên quan, cách nào đã thử và hỏng) sẽ mất nếu nhánh mới không biết gì về nó. Khi chọn một entry khác, giao diện hỏi “Summarize branch?” với ba lựa chọn: không tóm tắt, tóm tắt, tóm tắt theo hướng dẫn riêng, trừ khi setting branchSummary.skipPrompt bật (interactive-mode.ts:5563–5569). Lựa chọn đi vào AgentSession.navigateTree().
Ví dụ minh hoạ: rời nhánh B–C–D để quay về nhánh E–F
Trước: Sau khi điều hướng tới F, có tóm tắt:
┌─ B ─ C ─ D ← lá cũ ┌─ B ─ C ─ D
A ───┤ A ───┤
└─ E ─ F └─ E ─ F ─ [S: tóm tắt B, C, D] ← lá mới
Tổ tiên chung: A. Entry được tóm tắt: B, C, D. S.fromId = D, S.parentId = F.
Gom entry
// Find common ancestor (deepest node that's on both paths)const oldPath = new Set(session.getBranch(oldLeafId).map((e) => e.id));const targetPath = session.getBranch(targetId);// targetPath is root-first, so iterate backwards to find deepest common ancestorlet commonAncestorId: string | null = null;for (let i = targetPath.length - 1; i >= 0; i--) { if (oldPath.has(targetPath[i].id)) { commonAncestorId = targetPath[i].id; break; }}// Collect entries from old leaf back to common ancestorconst entries: SessionEntry[] = [];let current: string | null = oldLeafId;while (current && current !== commonAncestorId) { const entry = session.getEntry(current); if (!entry) break; entries.push(entry); current = entry.parentId;}// Reverse to get chronological orderentries.reverse();return { entries, commonAncestorId };Tổ tiên chung là entry sâu nhất nằm trên cả đường của lá cũ lẫn đường của đích. Khác với compaction, hàm này không dừng ở ranh giới compaction: một compaction trên nhánh bị bỏ cũng được gom, và bản tóm tắt của nó trở thành một phần nội dung cần tóm tắt.
// Second pass: walk from newest to oldest, adding messages until token budgetfor (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; const message = getMessageFromEntry(entry); if (!message) continue; // Extract file ops from assistant messages (tool calls) extractFileOpsFromMessage(message, fileOps); const tokens = estimateTokens(message); // Check budget before adding if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) { // If this is a summary entry, try to fit it anyway as it's important context if (entry.type === "compaction" || entry.type === "branch_summary") { if (totalTokens < tokenBudget * 0.9) { messages.unshift(message); totalTokens += tokens; } } // Stop - we've hit the budget break; } messages.unshift(message); totalTokens += tokens;}contextWindow - reserveTokens của branchSummary (mặc định 16.384; contextWindow không rõ thì lấy 128.000, branch-summarization.ts:311–313). Nhánh quá dài thì phần cũ nhất bị bỏ, trừ các entry tóm tắt, được ưu tiên nhét vào nếu tổng còn dưới 90% ngân sách.getMessageFromEntry() bỏ qua kết quả tool (“context is in assistant's tool call”) và các entry không mang nội dung hội thoại (:156–180). Danh sách file được gom từ mọi entry, kể cả phần vượt ngân sách, và từ các bản tóm tắt nhánh lồng nhau do chính Pi tạo (:200–216).
Prompt và kết quả
const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.Summary of that exploration:`;const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later.Use this EXACT format:## Goal[What was the user trying to accomplish in this branch?]## Constraints & Preferences- [Any constraints, preferences, or requirements mentioned]- [Or "(none)" if none were mentioned]## Progress### Done- [x] [Completed tasks/changes]### In Progress- [ ] [Work that was started but not finished]### Blocked- [Issues preventing progress, if any]## Key Decisions- **[Decision]**: [Brief rationale]## Next Steps1. [What should happen next to continue this work]Keep each section concise. Preserve exact file paths, function names, and error messages.`;generateBranchSummary() dùng cùng system prompt tóm tắt, cùng serializeConversation() và cùng completeSummarization() như compaction. Hướng dẫn riêng của người dùng được nối thêm, hoặc thay hẳn prompt nếu replaceInstructions bật (:327–335). Kết quả được thêm lời dẫn “The user explored a different conversation branch before returning here.” ở đầu và danh sách file ở cuối (:365–372). Khác compaction ở hai điểm: request không truyền mức suy nghĩ, và kết quả lỗi được trả về dưới dạng giá trị ({ error }, { aborted }) thay vì throw.
Bản tóm tắt nằm ở đâu
if (targetEntry.type === "message" && targetEntry.message.role === "user") { // User message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; editorText = contentText(targetEntry.message.content, "");} else if (targetEntry.type === "custom_message") { // Custom message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; editorText = contentText(targetEntry.content, "");} else { // Non-user message: leaf = selected node newLeafId = targetId;}if (summaryText) { // Create summary at target position (can be null for root) const summaryId = this.sessionManager.branchWithSummary( newLeafId, summaryText, summaryDetails, fromExtension, summaryUsage, ); summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;Đích là một tin nhắn user thì lá mới là cha của nó, và nội dung tin nhắn được đưa vào editor để người dùng sửa rồi gửi lại; đích là entry khác thì lá mới chính là đích. Bản tóm tắt được gắn vào vị trí lá mới:
/** * Start a new branch with a summary of the abandoned path. * Same as branch(), but also appends a branch_summary entry that captures * context from the abandoned conversation path. */branchWithSummary( branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean, usage?: Usage,): string { if (branchFromId !== null && !this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } const fromId = this.leafId ?? "root"; this.leafId = branchFromId; const entry: BranchSummaryEntry = { type: "branch_summary", id: generateId(this.byId), parentId: branchFromId, timestamp: new Date().toISOString(), fromId, summary, details, usage, fromHook, }; this._appendEntry(entry); return entry.id;}Entry branch_summary có parentId là đích, fromId là lá cũ (hoặc "root"), rồi trở thành lá. Từ đó mọi request trên nhánh mới đều mang bản tóm tắt, dưới dạng tin nhắn user bắt đầu bằng “The following is a summary of a branch that this conversation came back from:” (messages.ts:170–175). Extension có thể huỷ việc điều hướng, thay bản tóm tắt hay đổi hướng dẫn qua session_before_tree (agent-session.ts:4084–4111).
Đánh đổi với prompt cache#
Provider tính rẻ hơn nhiều cho phần input trùng tiền tố với một request gần đây: đó là prompt cache. Cache khớp theo tiền tố token chính xác: “If a token changes in the middle, everything after that token is a different continuation” (Prompt Caching In Agents). Trong một session bình thường, mỗi request chỉ nối thêm vào cuối request trước, nên gần như toàn bộ input trúng cache; chỉ phần mới phải trả giá đầy đủ.
Compaction phá điều đó, một cách có chủ ý. Sau khi nén, request bắt đầu bằng bản chụp prompt, rồi bản tóm tắt, rồi phần giữ lại. Bản tóm tắt là văn bản mới, nên mọi thứ từ đó trở đi trượt cache, kể cả 20.000 token giữ nguyên văn: chúng giống hệt trước, nhưng đứng sau một tiền tố khác. Bài How Compaction Works in Pi nói đúng điều này: phần giữ lại phải được tính lại, và cache chỉ trở lại từ các request sau. Phần đầu của request (bản chụp prompt và tool) có thể vẫn trùng với request trước nếu prompt và bộ tool không đổi trong session.
Chi phí của một lần nén vì thế có hai phần: request tóm tắt, với input là hội thoại đã tuần tự hoá và không ghi cache; và request kế tiếp, phải xử lý lại toàn bộ phần sau bản chụp. Đổi lại, mọi request sau đó nhỏ đi nhiều lần.
Pi chọn trả chi phí đó một lần, ở những thời điểm rõ ràng, thay vì cắt tỉa context dần dần. Bài về prompt caching giải thích: xoá nội dung ở giữa làm đổi tiền tố ngay tại chỗ xoá, phần phía sau phải xử lý lại, và chi phí viết lại có thể vượt phần tiết kiệm được; kết quả tool cũ còn có thể chứa bằng chứng mà các quyết định sau dựa vào. Vì vậy Pi “prefers a stable, append-oriented transcript”, và trong thống kê session coi compaction là “a cache reset rather than a cache failure”. Hai lựa chọn đã gặp ở trên đi cùng hướng: request tóm tắt không ghi cache (docs/compaction.md:23), và ngưỡng mặc định nằm sát giới hạn cửa sổ, nên số lần nén, cũng là số lần mất cache, ở mức thấp.
Ví dụ có số liệu: một session vượt ngưỡng#
Một session dài với mô hình có cửa sổ 200.000 token và cấu hình mặc định. Mọi số liệu dưới đây là minh hoạ; số token của từng entry là ước lượng ký tự/4 như Pi tính, trừ usage do provider báo.
Ví dụ minh hoạ, bước 1: vượt ngưỡng giữa hai lượt
Ngưỡng = contextWindow - reserveTokens = 200.000 - 16.384 = 183.616
e41 (assistant) vừa gọi bash("npm test"). Usage provider báo cho e41:
input 3.120 + output 1.830 + cacheRead 176.900 + cacheWrite 2.450 = 184.300
e42 (kết quả tool): output của npm test, 24.200 ký tự ≈ 6.050
─────────
Trước khi gọi mô hình lần nữa (prepareNextTurnWithContext) ≈ 190.350 > 183.616
→ _compactBeforeNextAssistantResponse() → _runAutoCompaction("threshold")Usage của e41 đã được tính từ provider; chỉ e42 là ước lượng. Đây đúng là trường hợp mà điểm kiểm tra giữa hai lượt (từ 0.84.4) tồn tại để bắt: không có nó, 6.050 token output của npm test sẽ được gửi đi cùng request kế tiếp trước khi kịp nén. Tiếp theo, prepareCompaction() tìm điểm cắt, đi ngược từ entry mới nhất:
Ví dụ minh hoạ, bước 2: điểm cắt (keepRecentTokens = 20.000)
entry nội dung ≈ token cộng dồn
e42 kết quả tool: npm test 6.050 6.050
e41 assistant: bash("npm test") 110 6.160
e40 kết quả tool: edit 15 6.175
e39 assistant: edit("src/cache.ts") 380 6.555
e38 kết quả tool: read src/cache.ts 7.400 13.955
e37 assistant: read("src/cache.ts") 90 14.045
e36 user: "Giờ thêm cache cho /users" 40 14.085
e35 assistant: "Đã sửa xong lỗi 500..." 450 14.535
e34 kết quả tool: git diff 3.900 18.435
e33 assistant: bash("git diff") 80 18.515
e32 kết quả tool: read src/api.ts 5.200 23.715 ← vượt 20.000
e31 assistant: read("src/api.ts")
e30 user: "Sửa lỗi 500 ở /api/users"
...
e32 là kết quả tool, không cắt được → điểm hợp lệ gần nhất phía sau: e33
firstKeptEntryId = e33, phần giữ lại ≈ 18.515 token
e33 là assistant giữa lượt → lượt bắt đầu ở e30 → isSplitTurn = true
messagesToSummarize = e1..e29 turnPrefixMessages = e30, e31, e32
tokensBefore = 190.350Không có compaction nào trước đó, nên previousSummary rỗng và phần lịch sử dùng SUMMARIZATION_PROMPT. Vì lượt bị tách, compact() gửi hai request, lần lượt:
Ví dụ minh hoạ, bước 3: hai request tóm tắt (cacheRetention: "none")
Request 1, lịch sử: e1..e29 tuần tự hoá, mỗi kết quả tool cắt còn 2.000 ký tự
input ≈ 38.000, output ≈ 1.400
Request 2, đầu lượt: e30..e32 với TURN_PREFIX_SUMMARIZATION_PROMPT
input ≈ 1.000, output ≈ 250
summary = [tóm tắt lịch sử]
---
**Turn Context (split turn):**
[tóm tắt đầu lượt]
<read-files> ... </read-files>
<modified-files> ... </modified-files> ≈ 7.000 ký tự ≈ 1.750 tokenEntry compaction được ghi, context được dựng lại, và lượt kế tiếp gửi đi một request nhỏ hơn tám lần:
Ví dụ minh hoạ, bước 4: context trước và sau Trước (request lẽ ra được gửi) Sau (request thật sự được gửi) ┌──────────────────────────────┐ ┌──────────────────────────────┐ │ system + tool ≈ 3.000│ │ system (bản chụp) ≈ 3.000│ │ e1..e29 ≈163.500│ │ tóm tắt (vai user) ≈ 1.800│ │ e30..e32 ≈ 5.330│ │ e33..e42 ≈ 18.515│ │ e33..e42 ≈ 18.515│ └──────────────────────────────┘ └──────────────────────────────┘ estimatedTokensAfter ≈ 23.300 tokensBefore = 190.350 Prompt cache ở request kế tiếp: bản chụp có thể trúng (≈ 3.000 nếu prompt không đổi); ≈ 20.300 token còn lại là trượt và được ghi cache mới.
Cái giá của lần nén này: khoảng 39.000 token input và 1.650 token output cho hai request tóm tắt, cộng chừng 20.000 token phải xử lý lại ở request kế tiếp. Cái được: request kế tiếp và các request sau đó bắt đầu từ khoảng 23.000 token thay vì 190.000, và session còn khoảng 160.000 token để đi tiếp trước lần nén sau. Lần nén sau sẽ tóm tắt từ e33 trở đi, gửi kèm bản tóm tắt này trong <previous-summary>, và gộp danh sách file của lần này vào.
Quyết định thiết kế#
- Bản tóm tắt là văn bản thuần, nằm trong session. Nó đọc được, sửa được, và mang sang provider khác được. Một số provider có compaction riêng phía server; với OpenAI đó là một khối mã hoá mà provider khác không đọc được, và “Pi never passes this sort of information to another provider” (The Session You Cannot Take With You). Compaction của Pi luôn chạy phía client, nên đổi mô hình giữa session vẫn mang theo được bản tóm tắt.
- Giữ nguyên văn phần gần nhất. Tóm tắt thì mất chi tiết, mà chi tiết quan trọng nhất (file đang sửa, lỗi vừa gặp) thường nằm ở vài lượt cuối. 20.000 token là đủ cho “roughly 5 to 20 turns”.
- Cập nhật thay vì tóm tắt lại từ đầu. Mỗi lần nén chỉ gửi phần mới cộng bản tóm tắt cũ, nên chi phí không tăng theo độ dài session, và prompt cập nhật dặn giữ mọi thứ đã có.
- Request riêng, hội thoại tuần tự hoá thành văn bản. Mô hình tóm tắt có system prompt riêng, không thấy tool nào (CHANGELOG.md:838), và đọc hội thoại như một tài liệu chứ không như một cuộc trò chuyện để trả lời tiếp. Tách riêng cũng là thứ cho phép extension dùng một mô hình khác.
- Thứ cần chính xác thì không giao cho mô hình. Danh sách file do code rút ra và nối vào; điểm cắt do code chọn, không bao giờ tách lời gọi tool khỏi kết quả của nó.
- Nén ở ranh giới tự nhiên. Trước prompt, giữa hai lượt, sau lần chạy; không cắt ngang một câu trả lời đang stream (cách làm này đã bị bỏ từ 0.17.0). Tràn ngữ cảnh chỉ được thử phục hồi một lần, để không rơi vào vòng lặp nén rồi lại tràn.
- Ghi thêm, không ghi đè. Compaction là một entry mới. Lịch sử đầy đủ còn nguyên trong file cho
/tree, cho thống kê và cho extension đọc lịch sử; chỉ phép chiếu gửi cho mô hình là thay đổi. - Thay được. Mọi thứ từ điểm cắt tới prompt đều có thể thay qua
session_before_compact, mà vẫn giữ cách ghi entry và dựng context của Pi.
Cạm bẫy và trường hợp biên#
- Tắt compaction tự động là tắt luôn đường phục hồi khi tràn ngữ cảnh.
_checkCompaction()thoát ngay khienabledlàfalse, trước cả nhánh xử lý lỗi tràn (agent-session.ts:3013). Khi đó lỗi “context too long” của provider kết thúc lần chạy; bạn phải tự gõ/compact, lệnh này vẫn chạy được. - Ước lượng token là thô và cố ý ước lượng dư. Khi không có usage thật của provider, Pi đếm khoảng bốn ký tự một token và tính mỗi ảnh như 4.800 ký tự (compaction.ts:276, compaction.ts:294–298). Với code nhiều ký hiệu hay văn bản không phải tiếng Anh, số thật có thể lệch đáng kể, nên compaction có thể chạy sớm hoặc muộn hơn bạn tính.
- Mô hình tóm tắt không đọc hết output của tool. Khi tuần tự hoá hội thoại, mỗi kết quả tool bị cắt còn 2.000 ký tự đầu, còn phần suy nghĩ (thinking) bị bỏ hẳn (utils.ts:94, utils.ts:107–120). Một chi tiết nằm sâu trong một log dài có thể không lọt vào bản tóm tắt. Thứ thật sự cần nhớ nên được ghi ra file (một
TODO.md, mộtPLAN.md) đúng như triết lý ban đầu của Pi. - Đổi sang mô hình có cửa sổ lớn hơn không kích hoạt compaction vì lỗi cũ. Lỗi tràn của mô hình trước bị bỏ qua khi tin nhắn đến từ mô hình khác, và mọi tin nhắn cũ hơn compaction mới nhất cũng không được xét lại (agent-session.ts:3018–3035).
- Virtual model được xét theo mô hình thật mà request được định tuyến tới. Nếu router chọn một mô hình cửa sổ nhỏ hơn, compaction có thể chạy ngay trước request đó dù mô hình bạn chọn còn dư chỗ (agent-session.ts:835–838). Xem chương Pi 1.0.
- Request ngay sau compaction đắt hơn bình thường. Prefix mới không có trong cache, nên phần giữ nguyên văn bị xử lý lại (mục Prompt cache). Đừng ngạc nhiên khi thấy footer báo trượt cache sau
/compact. - Compaction không làm file session nhỏ lại. Nó chỉ nối thêm một entry; file vẫn lớn dần, và
/treevẫn cho quay về trước điểm cắt. Muốn một file gọn, dùng fork hoặc clone sang session mới (chương Session).
Đọc code ở đâu#
| Đọc | Để hiểu |
|---|---|
| docs/compaction.md | Mô tả chính thức: khi nào nén, giữ lại gì, cấu hình ra sao. |
| DEFAULT_COMPACTION_SETTINGS, shouldCompact() | Ngưỡng: cửa sổ ngữ cảnh trừ reserveTokens. |
| _checkCompaction(), _runAutoCompaction(), _compactBeforeNextAssistantResponse() | Ba điểm kích hoạt trong AgentSession và đường phục hồi khi tràn. |
| findCutPoint(), prepareCompaction() | Chọn điểm cắt theo keepRecentTokens, không tách tool call khỏi kết quả, xử lý lượt bị cắt đôi. |
| SUMMARIZATION_PROMPT, UPDATE_…, SUMMARIZATION_SYSTEM_PROMPT | Nguyên văn các prompt tóm tắt. |
| compact() | Chạy tóm tắt (một hoặc hai request) và lắp kết quả. |
| extractFileOpsFromMessage(), serializeConversation() | Danh sách file đọc/sửa và cách hội thoại được biến thành văn bản để tóm tắt. |
| CompactionEntry | Entry được ghi vào session. |
| collectEntriesForBranchSummary(), generateBranchSummary() | Tóm tắt nhánh khi rời đi bằng /tree. |
| examples/extensions/custom-compaction.ts | Thay compaction bằng cách của bạn qua session_before_compact. |
Tóm tắt#
- Compaction thay phần lịch sử cũ bằng một bản tóm tắt có cấu trúc, giữ nguyên văn khoảng
keepRecentTokens(mặc định 20.000) token gần nhất, và kích hoạt khi ngữ cảnh vượt cửa sổ trừreserveTokens(mặc định 16.384). - Nó chạy ở ranh giới tự nhiên: sau một lượt, trước request kế tiếp, khi provider báo tràn (thử một lần), hoặc khi bạn gõ
/compact. - Bản tóm tắt là văn bản thuần trong một entry
compaction; lần nén sau cập nhật nó thay vì viết lại, và danh sách file đọc/sửa do code tính chứ không do mô hình nhớ. - Không có gì bị xoá: phép chiếu ra ngữ cảnh mô hình bắt đầu từ bản tóm tắt và
firstKeptEntryId, còn toàn bộ lịch sử vẫn ở trong file. - Tóm tắt nhánh dùng cùng bộ máy khi bạn rời một nhánh qua
/tree; extension có thể thay cả hai.
11Extensions, skills, templates, themes, packages#
Vai trò#
Lõi của Pi cố ý nhỏ: bốn tool mặc định, một system prompt ngắn, không có plan mode, không có sub-agent, không có danh sách việc cần làm (todo) dựng sẵn. Bài viết gốc của Mario Zechner giải thích từng chỗ thiếu đó: danh sách todo thường làm mô hình rối hơn là giúp, plan mode của Claude Code không cho anh quan sát quá trình lập kế hoạch, còn với sub-agent thì người dùng "zero visibility", không thấy gì về việc nó làm (What I learned building an opinionated and minimal coding agent). Nhưng quy trình làm việc của mỗi người một khác. Người này muốn hỏi xác nhận trước mỗi lệnh rm -rf, người kia muốn một chế độ chỉ đọc để lập kế hoạch, một nhóm khác cần provider nội bộ với đăng nhập SSO. Đưa tất cả vào lõi sẽ làm lõi phình ra và áp một lựa chọn cho mọi người.
Câu trả lời của Pi là một hệ thống mở rộng đủ mạnh để dựng lại chính những tính năng nó bỏ qua. Extension là module TypeScript thêm hành vi có thể thực thi vào Pi: tool, lệnh, xử lý sự kiện, provider, trạng thái session, giao diện terminal (docs/extensions.md:3). Nó chạy ngay trong tiến trình Pi với cùng quyền hệ điều hành, nên đọc được prompt, lời gọi tool, tệp, credential và lịch sử session (docs/extensions.md:5). Không có hộp cát: extension là mã bạn tin như tin chính Pi.
Armin Ronacher mô tả cách dùng điển hình: không tự viết extension mà bảo agent viết, rồi nạp lại để thử ngay, vì Pi có sẵn cơ chế nạp lại nóng (hot reload) để agent viết code, nạp lại rồi tự kiểm thử. Các extension anh giới thiệu trong bài (/answer, /todos, /review, /control, /files) đều do agent viết theo đặc tả của anh. Code ở v1.4.0 chuẩn bị cho đúng việc đó. Phần mặc định của system prompt cho mô hình biết tài liệu và ví dụ của Pi nằm ở đâu và phải đọc gì khi người dùng hỏi về extension:
promptSections.docs = `Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):- Main documentation: ${getReadmePath()}- Additional docs: ${getDocsPath()}- Examples: ${getExamplesPath()} (extensions, custom tools, SDK)- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md), MCP servers (docs/mcp.md), codemode scripts and non-LLM models such as classifiers and image models (docs/codemode.md)- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)`;config.ts, nên trỏ đúng tới bản cài đặt đang chạy dù là mã nguồn, gói npm hay binary. Lệnh /reload (mục nạp extension) hoàn tất vòng viết, nạp lại, thử.Chương này gọi chung các thứ có thể thêm vào Pi là tài nguyên (resource). Có bốn loại tài nguyên được khám phá từ đĩa và một cách đóng gói chúng:
| Loại | Dạng | Thêm được gì |
|---|---|---|
| Extension | Tệp .ts/.js export một factory | Tool, lệnh /, phím tắt, cờ CLI, xử lý sự kiện, provider, MCP server, virtual model, renderer, UI |
| Skill | Thư mục chứa SKILL.md | Hướng dẫn chuyên biệt, chỉ nạp vào ngữ cảnh khi cần |
| Prompt template | Tệp Markdown | Lệnh /tên mở rộng thành một prompt có tham số |
| Theme | Tệp JSON | Bảng màu cho TUI và bản xuất HTML |
| Pi package | Thư mục, gói npm hoặc repo git | Phân phối bốn loại trên thành một đơn vị: pi install |
Bên cạnh đó là hai điểm tuỳ biến nhỏ hơn: keybindings.json và provider tự viết (một dạng extension). Ai dùng những thứ này? DefaultResourceLoader khám phá và nạp chúng mỗi khi một phiên được dựng và mỗi lần /reload; AgentSession tạo ExtensionRunner từ kết quả và nối runner vào các hook của vòng lặp agent (chương AgentSession). Ngay cả MCP, codemode, tool search và provider llama.cpp cũng là extension dựng sẵn, nạp qua đúng cơ chế này dưới tên builtin:<name>; phần ruột của chúng thuộc chương Codemode.
Mô hình tư duy#
Hãy hình dung ba tầng. Tầng dưới là các nguồn trên đĩa và trong settings. Tầng giữa giải các nguồn đó thành danh sách đường dẫn đã bật hay tắt, rồi nạp từng loại thành đối tượng trong bộ nhớ. Tầng trên là phiên đang chạy: runner phát sự kiện cho extension, sổ đăng ký tool trộn tool dựng sẵn với tool của extension, system prompt liệt kê skill.
Nguồn Giải và nạp (mỗi lần dựng phiên, mỗi lần /reload)
~/.pi/agent/extensions|skills|... ┐
.pi/extensions|skills|... (cần trust) ├─► DefaultPackageManager.resolve()
settings: packages, extensions,... │ → ResolvedResource { path, enabled, metadata }
-e, --skill, --theme ... (CLI) │
builtin:mcp, builtin:codemode ... ┘ DefaultResourceLoader.reload()
├─ extensions ─► loader.ts (jiti) ─► Extension[] + ExtensionRuntime
├─ skills ─────► loadSkills() ──────► Skill[]
├─ prompts ────► loadPromptTemplates() ► PromptTemplate[]
└─ themes ─────► loadThemeFromPath() ─► Theme[]
│
Phiên đang chạy ▼
AgentSession._buildRuntime()
ExtensionRunner(extensions, runtime) ── bindCore(): action thật thay cho stub
sổ tool: read/bash/edit/write + tool của extension (bọc bởi wrapToolDefinition)
system prompt: snippet tool, guideline, danh sách skill
│ hook cài vào Agent (pi-agent-core)
▼
transformContext ─► context beforeToolCall ─► tool_call finishTurn ─► turn_end
onPayload ───────► before_provider_request afterToolCall ─► tool_result
/reload; các hook của Agent thì cài một lần và luôn đọc runner hiện tại.Từ vựng dùng trong chương:
- factory
- Hàm mà module extension export mặc định, kiểu
(pi: ExtensionAPI) => void | Promise<void>. Pi chờ factory bất đồng bộ chạy xong rồi mới khởi động tiếp. pi(ExtensionAPI)- Đối tượng factory nhận. Các hàm
register*vàonghi vào đối tượngExtensioncủa riêng extension đó; các hàm hành động (sendMessage,appendEntry,setActiveTools...) uỷ cho runtime dùng chung. - Extension
- Kết quả nạp một module: các Map handler, tool, lệnh, cờ, phím tắt, renderer, cùng
sourceInfocho biết nó đến từ đâu. - ExtensionRuntime
- Trạng thái chung của mọi extension trong một lần nạp: giá trị cờ CLI, các đăng ký provider đang chờ, sổ MCP server, và các hàm hành động. Trước khi runner được bind, các hàm hành động là stub ném lỗi.
- ExtensionRunner
- Lớp phát sự kiện cho extension, theo thứ tự nạp, với cách gộp kết quả riêng cho từng loại sự kiện. Mỗi lần
/reloadtạo runner mới. - ctx (ExtensionContext)
- Đối tượng truyền cho mỗi handler: UI, cwd, session (chỉ đọc), mô hình, tín hiệu huỷ. Mọi getter kiểm tra ctx còn hiệu lực hay đã cũ (stale).
- scope, origin
- Siêu dữ liệu của một tài nguyên: scope là
user,projecthoặctemporary(CLI); origin làtop-levelhoặcpackage. - trust
- Quyết định tin cậy dự án. Mọi tài nguyên dưới
.pi/của dự án chỉ được nạp sau khi dự án được tin cậy.
Nạp một extension: jiti, virtual module, built-in#
Ví dụ ngắn nhất trong tài liệu là một tệp ~/.pi/agent/extensions/hello.ts đăng ký lệnh /hello; chạy thử một tệp bất kỳ thì dùng pi --extension ./hello.ts (docs/extensions.md:17). Không có bước biên dịch. Phần này lần theo đường đi từ tệp đó tới một đối tượng Extension trong bộ nhớ.
Tìm ở đâu: các nguồn và một thứ tự
Việc khám phá không nằm trong loader.ts mà trong DefaultPackageManager.resolve(), hàm giải cùng lúc cả bốn loại tài nguyên. (loader.ts cũng export discoverAndLoadExtensions() (loader.ts:831), nhưng đó là helper cho người dùng SDK; CLI không gọi nó.)
async resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths> { const accumulator = this.createAccumulator(); const globalSettings = this.settingsManager.getGlobalSettings(); const projectSettings = this.settingsManager.getProjectSettings(); // Collect all packages with scope (project first so cwd resources win collisions) const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = []; for (const pkg of projectSettings.packages ?? []) { allPackages.push({ pkg, scope: "project" }); } for (const pkg of globalSettings.packages ?? []) { allPackages.push({ pkg, scope: "user" }); } // Dedupe: project scope wins over global for same package identity const packageSources = this.dedupePackages(allPackages); await this.resolvePackageSources(packageSources, accumulator, onMissing); this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir); // Built-in extensions are enabled unless the user `extensions` setting excludes them, for example // with `-builtin:mcp`. A matching `+`, `-`, or `!` entry in the project setting overrides that. for (const name of this.builtinExtensions) { const path = `${BUILTIN_PATH_PREFIX}${name}`; const projectEnabled = applyAutoloadDisabledPatterns( [path], getOverridePatterns(projectSettings.extensions ?? []), projectBaseDir, ).get(path); this.addResource( accumulator.extensions, path, { source: "builtin", scope: projectEnabled === undefined ? "user" : "project", origin: "top-level" }, projectEnabled ?? isEnabledByOverrides(path, globalSettings.extensions ?? [], globalBaseDir), ); } return this.toResolvedPaths(accumulator);}extensions, skills, prompts, themes ghi tay trong settings.json, dự án trước rồi người dùng. Mỗi tài nguyên mang cờ enabled: tài nguyên bị tắt vẫn được liệt kê (để pi config hiển thị) nhưng không được nạp.Trong một thư mục extension (~/.pi/agent/extensions/ hoặc .pi/extensions/), quy tắc khám phá đơn giản: mỗi tệp .ts/.js trực tiếp là một extension; mỗi thư mục con có package.json với trường pi.extensions, hoặc có index.ts/index.js, là một extension. Không đệ quy sâu hơn một cấp, tên bắt đầu bằng dấu chấm và node_modules bị bỏ qua, các tệp .gitignore, .ignore, .fdignore được tôn trọng (package-manager.ts:599). Thư mục .pi/extensions/ của dự án chỉ được quét khi dự án đã được tin cậy (package-manager.ts:2484).
Kết quả được sắp theo một hạng ưu tiên trước khi trả về:
/** * Compute a numeric precedence rank for a resource based on its metadata. * Lower rank = higher precedence. Used to sort resolved resources so that * name-collision resolution ("first wins") produces the correct outcome. * * Precedence (highest to lowest): * 0 project + settings entry (source: "local", scope: "project") * 1 project + auto-discovered (source: "auto", scope: "project") * 2 user + settings entry (source: "local", scope: "user") * 3 user + auto-discovered (source: "auto", scope: "user") * 4 package resource (origin: "package") */function resourcePrecedenceRank(m: PathMetadata): number { // Built-in extensions load after file and package extensions, whichever scope enables them. if (m.source === "builtin") return 5; if (m.origin === "package") return 4; const scopeBase = m.scope === "project" ? 0 : 2; return scopeBase + (m.source === "local" ? 0 : 1);}Thứ tự này có hai hệ quả. Với skill, prompt và theme, khi hai tài nguyên trùng tên thì cái đứng trước thắng. Với extension, nó là thứ tự nạp, và vì handler chạy theo thứ tự nạp, nó cũng là thứ tự extension nhìn thấy sự kiện. Trước tất cả là các đường dẫn truyền bằng -e trên dòng lệnh: DefaultResourceLoader.reload() ghép cliEnabledExtensions lên đầu, và --no-extensions giữ lại chỉ chúng (resource-loader.ts:577). Nguồn -e có scope temporary và có thể là npm: hoặc git:, được cài vào một thư mục tạm thay vì ghi vào settings (package-manager.ts:997).
jiti và virtual module: một extension, ba kiểu chạy
Vấn đề: hello.ts viết import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" và có thể import typebox, @earendil-works/pi-tui. Pi lại chạy được theo ba cách: từ mã nguồn TypeScript trong repo, từ gói npm đã build, hoặc từ một binary độc lập (Bun hay Node SEA) không có thư mục node_modules nào trên đĩa. Trong cả ba trường hợp, import của extension phải trỏ tới chính module mà tiến trình Pi đang dùng, không phải một bản sao: theme toàn cục, các lớp như CustomEditor và sổ provider phải là một. Chương trình dịch và nạp TypeScript lúc chạy là jiti:
const isNodeSeaBinary = ("sea" in process.features && process.features.sea === true) || process.getBuiltinModule("node:sea")?.isSea() === true;const isTypeScriptSourceRuntime = !isBunBinary && path.extname(fileURLToPath(import.meta.url)) === ".ts";const usesEmbeddedModules = isBunBinary || isNodeSeaBinary || isBundledNode;async function loadExtensionModule(extensionPath: string, cacheToken?: ExtensionCacheToken) { if (isCurrentCacheToken(cacheToken)) { const cachedFactory = extensionCache.get(extensionPath); if (cachedFactory) { return cachedFactory; } } const createJitiImpl = await getCreateJiti(); // Compiled binaries and the bundled Node distribution use embedded modules. // Source TypeScript reuses host modules and root tsconfig paths. Unbundled // Node builds use dist aliases and do not need the bundled virtual modules. const resolutionOptions = usesEmbeddedModules ? { virtualModules: await getVirtualModules(), tryNative: false } : isTypeScriptSourceRuntime ? { virtualModules: await getVirtualModules(), tsconfigPaths: true } : { alias: getAliases() }; const jiti = createJitiImpl(import.meta.url, { moduleCache: false, ...resolutionOptions, }); const module = await jiti.import(extensionPath, { default: true }); const factory = module as ExtensionFactory; if (typeof factory !== "function") { return undefined; } if (isCurrentCacheToken(cacheToken)) { extensionCache.set(extensionPath, factory); } return factory;}virtualModules với tryNative: false, để mọi import đều đi qua jiti và bị bắt lại. Mã nguồn TypeScript cũng dùng virtual module cộng với paths của tsconfig gốc. Bản npm chưa đóng gói ánh xạ tên gói sang tệp dist đã cài bằng alias (getAliases()). Module không export mặc định một hàm thì bị từ chối với lỗi "does not export a valid factory function".// This import is safe because loader.ts exports are not re-exported from index.ts.// Extensions can therefore import from @earendil-works/pi-coding-agent.import * as bundledPiCodingAgent from "../../index.ts";/** Modules available to extensions in source and compiled binary runtimes. */export const VIRTUAL_MODULES: Record<string, unknown> = { typebox: bundledTypebox, "typebox/compile": bundledTypeboxCompile, "typebox/value": bundledTypeboxValue, "@sinclair/typebox": bundledTypebox, "@sinclair/typebox/compile": bundledTypeboxCompile, "@sinclair/typebox/value": bundledTypeboxValue, "@earendil-works/pi-agent-core": bundledPiAgentCore, "@earendil-works/pi-tui": bundledPiTui, // Extensions resolve the pi-ai root to the compat entrypoint (a strict // superset of the core entrypoint): existing extensions using the old // global API keep working at runtime until compat is removed. "@earendil-works/pi-ai": bundledPiAiCompat, "@earendil-works/pi-ai/compat": bundledPiAiCompat, "@earendil-works/pi-ai/models": bundledPiAiModels, "@earendil-works/pi-ai/oauth": bundledPiAiOauth, "@earendil-works/pi-ai/providers/all": bundledPiAiProviders, "@earendil-works/pi-coding-agent": bundledPiCodingAgent,@mariozechner/* (đổi sang @earendil-works/* ở bản 0.74.0, CHANGELOG.md:2208) và @sinclair/typebox được ánh xạ vào cùng các đối tượng, nên extension viết cho phiên bản cũ vẫn chạy. Gốc @earendil-works/pi-ai trỏ vào entrypoint compat, một tập cha của entrypoint lõi giữ API toàn cục cũ.Rủi ro hai bản sao module là có thật trong repo: biến theme toàn cục được cất trên globalThis dưới một Symbol.for(...) chính vì "node + jiti in dev mode" có thể nạp module theme hai lần (theme.ts:646). Cùng lý do, một Pi package không được liệt kê các gói do host cung cấp trong dependencies: bản cài vật lý có thể đi vòng qua ánh xạ của loader. Resource loader phát hiện cấu hình này và in cảnh báo (resource-loader.ts:53, docs/packages.md:88). Các thư viện khác thì extension tự mang theo trong package.json bên cạnh nó; jiti phân giải chúng từ node_modules của extension, như ví dụ with-deps/ (packages/coding-agent/examples/extensions/with-deps/package.json).
Hai chi tiết nhỏ về chi phí khởi động: jiti và Babel chỉ được import khi có extension đầu tiên cần nạp (getCreateJiti() và getVirtualModules() dùng import động có cache, loader.ts:47); binary dùng jiti/static để bộ đóng gói nhúng được bộ biến đổi Babel, còn Node thường dùng bản lười chỉ tải Babel khi nạp native thất bại (packages/coding-agent/src/core/extensions/jiti-loader.ts, packages/coding-agent/src/core/extensions/jiti-static-loader.ts).
Chạy factory: đăng ký hai pha
Có module rồi, loader tạo một Extension rỗng, dựng một ExtensionAPI riêng cho nó, và gọi factory:
async function initializeExtension( factory: ExtensionFactory, extensionPath: string, resolvedPath: string, cwd: string, eventBus: EventBus, runtime: ExtensionRuntime,): Promise<Extension> { const extension = createExtension(extensionPath, resolvedPath); const load = createExtensionAPI(extension, runtime, cwd, eventBus); try { await factory(load.api); load.commit(); } catch (error) { load.discard(); throw error; } time(`${extensionPath} factory`, "extensions"); return extension;} try { const factory = await loadExtensionModule(resolvedPath, cacheToken); time(`${extensionPath} module import`, "extensions"); if (!factory) { return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` }; } const extension = await initializeExtension(factory, extensionPath, resolvedPath, cwd, eventBus, runtime); return { extension, error: null }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { extension: null, error: `Failed to load extension: ${message}` }; }errors ("Failed to load extension: ..."), hiển thị lúc khởi động; vòng lặp trong loadExtensionsInternal() chuyển sang tệp kế tiếp (loader.ts:694).commit() và discard() là phần đáng chú ý. Trong lúc factory chạy, API ở trạng thái loading: các thay đổi lên runtime dùng chung (đăng ký provider, MCP server, virtual model) chưa được áp dụng mà xếp vào pendingRuntimeChanges. Factory chạy hết mới commit; factory ném lỗi thì mọi thứ đang chờ bị huỷ, các đăng ký trên event bus làm trong lúc nạp bị gỡ, và API chuyển sang failed để mọi lời gọi sau đó ném lỗi:
const pendingFlagValues = new Map<string, boolean | string>();const pendingRuntimeChanges: Array<() => void> = [];const loadingUnsubscribers: Array<() => void> = [];let state: "loading" | "active" | "failed" = "loading";const assertActive = () => { if (state === "failed") { throw new Error(`Extension "${extension.path}" failed to load and its API is no longer active.`); } runtime.assertActive();};const applyRuntimeChange = (change: () => void) => { if (state === "loading") pendingRuntimeChanges.push(change); else change();};const clearPending = () => { pendingFlagValues.clear(); pendingRuntimeChanges.length = 0; loadingUnsubscribers.length = 0;};return { api, commit: () => { if (state !== "loading") return; runtime.assertActive(); for (const [name, value] of pendingFlagValues) { if (!runtime.flagValues.has(name)) runtime.flagValues.set(name, value); } for (const apply of pendingRuntimeChanges) apply(); state = "active"; clearPending(); }, discard: () => { if (state !== "loading") return; state = "failed"; for (const unsubscribe of loadingUnsubscribers) unsubscribe(); clearPending(); },};Một ràng buộc khác: trong lúc nạp, các hàm hành động của runtime là stub ném lỗi. Runtime được tạo trước mọi extension, và chỉ khi AgentSession dựng runner và gọi bindCore() thì các hàm thật mới được chép vào:
export function createExtensionRuntime(): ExtensionRuntime { const notInitialized = () => { throw new Error("Extension runtime not initialized. Action methods cannot be called during extension loading."); }; const state: { staleMessage?: string } = {}; const eventBusUnsubscribers = new Set<() => void>(); const assertActive = () => { if (state.staleMessage) { throw new Error(state.staleMessage); } }; const runtime: ExtensionRuntime = { sendMessage: notInitialized, sendUserMessage: notInitialized, appendEntry: notInitialized,Vì thế một factory gọi pi.sendMessage() sẽ thất bại và extension bị bỏ. Tài liệu còn khuyên không mở tiến trình, socket, watcher hay timer trong factory, vì có những lần Pi nạp extension mà không mở phiên nào: các lệnh pi config, pi install, pi list nạp extension của người dùng chỉ để chúng trả lời câu hỏi trust (package-manager-cli.ts:782); tài nguyên sống lâu nên mở trong session_start và đóng trong session_shutdown (docs/extensions.md:56).
builtin:<name>: extension dựng sẵn như một tệp
CLI có bốn extension dựng sẵn, khai báo bằng kiểu InlineExtension với cờ builtin:
export const builtInExtensions: InlineExtension[] = [ { name: "llama.cpp", factory: llamaExtension, builtin: true }, // Replaceable: an extension that registers `codemode`, `tool_search`, or `/mcp` (such as a third-party // MCP extension) takes over instead of running alongside the built-in one. { name: "codemode", factory: codemodeExtension, replaceable: true, builtin: true }, { name: "tool-search", factory: toolSearchExtension, replaceable: true, builtin: true }, { name: "mcp", factory: mcpExtension, replaceable: true, builtin: true },];export type InlineExtension = | ExtensionFactory | { /** * Display name shown as `<inline:name>` in the startup Extensions list and errors. With * `builtin`, the extension is named `builtin:name` in errors and diagnostics. */ name: string; factory: ExtensionFactory; /** Omit this extension from the startup Extensions list. */ hidden?: boolean; /** * Leave this extension out when another extension registers a tool, command, or flag with a * name it registers during loading, instead of reporting a conflict. The CLI's built-in MCP, * codemode, and tool search extensions use it, so for example an MCP extension that registers * `/mcp` replaces the built-in MCP support. The factory still runs, so it should only register * tools, commands, flags, and event handlers. */ replaceable?: boolean; /** * Supply the code of the `builtin:<name>` extension instead of loading as an inline extension. * `builtin:<name>` is an extension resource like a file: it loads by default, `pi config` lists * it, `-builtin:<name>` in the `extensions` setting and `--no-extensions` disable it, and * `-e builtin:<name>` loads it explicitly. It is hidden from the startup Extensions list and * loads after project trust is resolved, so it cannot handle `project_trust`. The CLI's built-in * extensions use it. */ builtin?: boolean; };Ý tưởng là coi một extension dựng sẵn như một tài nguyên có đường dẫn, chỉ khác là đường dẫn đó tổng hợp (synthetic): builtin:mcp không trỏ tới tệp nào. Nhờ vậy mọi công cụ quản lý tài nguyên dùng được cho nó. resolve() thêm mỗi built-in như một extension với source: "builtin", bật trừ khi setting extensions loại nó bằng -builtin:mcp; mục +/- trong settings dự án thắng settings người dùng (dòng 977 đến 991 ở trên). Hạng ưu tiên 5 đặt chúng sau mọi extension tệp và gói. Khi tới lượt nạp, đường dẫn builtin: được tra trong bảng built-in thay vì đọc đĩa:
/** Load extension paths: files from disk and `builtin:<name>` paths from the built-in extensions. */private async loadExtensionPaths(paths: string[], runtime?: ExtensionRuntime): Promise<LoadExtensionsResult> { const isBuiltinPath = (path: string) => path.startsWith(BUILTIN_PATH_PREFIX); const result = await loadExtensionsCached( paths.filter((path) => !isBuiltinPath(path)), this.cwd, this.eventBus, runtime, ); for (const path of paths.filter(isBuiltinPath)) { const builtin = this.builtinExtensions.get(path.slice(BUILTIN_PATH_PREFIX.length)); if (!builtin) { result.errors.push({ path, error: `Unknown built-in extension: ${path}` }); continue; } try { const extension = await loadExtensionFromFactory( builtin.factory, this.cwd, this.eventBus, result.runtime, path, ); extension.hidden = true; extension.replaceable = builtin.replaceable === true; result.extensions.push(extension); } catch (error) { result.errors.push({ path, error: error instanceof Error ? error.message : "failed to load extension" }); } } return result;}Có nhiều cách tắt một built-in: mục -builtin:<name> trong extensions, phần Built-in của pi config, --no-extensions (tắt cả bốn), hay --no-mcp cho riêng MCP (main.ts:788); ngược lại -e builtin:mcp nạp tường minh (docs/settings.md:163). Ba built-in có cờ replaceable: nếu một extension khác đăng ký tool, lệnh hay cờ trùng tên với chúng, built-in bị loại thay vì chạy song song, kèm một cảnh báo hướng dẫn dùng pi config:
function omitReplacedExtensions( extensions: Extension[], warnings?: Array<{ path: string; warning: string }>,): Extension[] { const names = (extension: Extension) => [ ...[...extension.tools.keys()].map((name) => `tool:${name}`), ...[...extension.commands.keys()].map((name) => `command:${name}`), ...[...extension.flags.keys()].map((name) => `flag:${name}`), ]; const taken = new Map( extensions .filter((extension) => !extension.replaceable) .flatMap((extension) => names(extension).map((name) => [name, extension] as const)), ); return extensions.filter((extension) => { if (!extension.replaceable) return true; const replacement = names(extension) .map((name) => ({ name, extension: taken.get(name) })) .find((value): value is { name: string; extension: Extension } => value.extension !== undefined); if (!replacement) return true; if (extension.path.startsWith(BUILTIN_PATH_PREFIX)) { const builtinName = extension.path.slice(BUILTIN_PATH_PREFIX.length); const [kind, rawName] = replacement.name.split(":", 2); const registeredName = kind === "command" ? `/${rawName}` : kind === "flag" ? `--${rawName}` : rawName; warnings?.push({ path: extension.path, warning: `Extension ${replacement.extension.path} registers ${kind} \`${registeredName}\`, so built-in extension \`${builtinName}\` was not loaded. To use \`${builtinName}\`, run \`pi config\` and make sure it is enabled under Built-in extensions, then disable or remove the existing extension. We recommend only having one or the other loaded at a time.`, }); } return false; });}/mcp thì built-in mcp không được dùng, tránh hai bên cùng kết nối một máy chủ. Factory của built-in vẫn chạy (việc so tên cần biết nó đăng ký gì), nên factory của extension replaceable chỉ nên đăng ký, không làm việc có tác dụng phụ (types.ts:2058).wrapper.ts: tool của extension nhận ctx
Tool đăng ký bằng pi.registerTool() là một ToolDefinition của coding agent, trong khi vòng lặp agent chỉ biết AgentTool của pi-agent-core. wrapper.ts nối hai bên, và chỉ làm đúng một việc:
export function wrapToolDefinition<TDetails = unknown>( definition: ToolDefinition<any, TDetails>, ctxFactory?: ToolContextFactory,): AgentTool<any, TDetails> { return { name: definition.name, label: definition.label, description: definition.description, parameters: definition.parameters, outputSchema: definition.outputSchema, constrainedSampling: definition.constrainedSampling, prepareArguments: definition.prepareArguments, executionMode: definition.executionMode, execute: (toolCallId, params, signal, onUpdate, ctx?: ExtensionToolContext) => definition.execute( toolCallId, params, signal, onUpdate, ctx ?? (ctxFactory?.(toolCallId, signal) as ExtensionToolContext), ), };}wrapRegisteredTool() truyền factory runner.createToolContext(toolCallId, signal) (wrapper.ts:17). Chú thích đầu tệp nói rõ: chặn và sửa lời gọi tool không nằm ở đây mà ở hook của AgentSession (mục lần theo).Tool dựng sẵn (read, bash, ...) cũng được bọc theo cùng cách, rồi tool của extension được ghi đè lên theo tên (agent-session.ts:3607). Hệ quả: một extension đăng ký tool tên bash sẽ thay hẳn tool bash dựng sẵn, như ví dụ sandbox/ cố ý làm (sandbox/index.ts:8). Giữa hai extension cùng đăng ký một tên, cái nạp trước thắng (runner.ts:636), và cả hai vẫn được nạp, xung đột được báo thành lỗi chẩn đoán (resource-loader.ts:1250).
/reload: nạp lại nóng
Vòng "viết, nạp lại, thử" mà Armin mô tả là lệnh /reload (hoặc ctx.reload() từ một lệnh extension):
async reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void> { const oldRunner = this._extensionRunner; const previousFlagValues = oldRunner.getFlagValues(); await emitSessionShutdownEvent(oldRunner, { type: "session_shutdown", reason: "reload" }); oldRunner.invalidate(); await this.settingsManager.reload(); this.syncQueueModesFromSettings(); resetApiProviders(); await this._resourceLoader.reload(); // Activate tools newly added to defaultTools. Removed ones stay active, and tools disabled // during the session stay disabled unless the setting newly adds them. const addedDefaultTools = getDefaultTools().filter((name) => !previousDefaultTools.has(name)); // Tools the new extensions register later, such as MCP tools, are pending until then. for (const name of this.getActiveToolNames()) this._pendingToolNames.add(name); this._buildRuntime({ activeToolNames: [...this.getActiveToolNames(), ...addedDefaultTools], flagValues: previousFlagValues, includeAllExtensionTools: true, }); if (hasBindings) { await options?.beforeSessionStart?.(); await this._extensionRunner.emit({ type: "session_start", reason: "reload" }); this._extensionRunner.reportUnhandledMcpServers(); await this.extendResourcesFromExtensions("reload"); }/reload khi agent đang chạy hoặc đang compaction (interactive-mode.ts:6403), sau đó nạp lại cả keybindings và theme.Mã mới thực sự được đọc lại từ đĩa vì hai lý do. jiti được tạo với moduleCache: false, nên mỗi lần import đọc và đánh giá lại tệp. Và bộ nhớ đệm factory của loader (Map theo đường dẫn, gắn với cwd và một số thế hệ) bị xoá ở mọi lần reload() trừ lần đầu của một resource loader (resource-loader.ts:516, loader.ts:132). Bộ nhớ đệm đó tồn tại để những lần nạp liền nhau trong cùng cwd không phải dịch lại TypeScript, chẳng hạn khi /new dựng một resource loader mới; hệ quả của nó nằm ở mục cạm bẫy.
Nạp tài nguyên và project trust#
Vấn đề: bạn clone một repo lạ có .pi/extensions/setup.ts rồi gõ pi trong đó. Nếu Pi nạp mọi thứ nó tìm thấy, tệp kia chạy với quyền của bạn trước khi bạn kịp đọc nó. Nhưng chặn mọi thứ cũng không ổn: một công ty có thể muốn một extension cá nhân tự động tin mọi repo dưới ~/work mà không hỏi. Pi giải bằng hai lượt nạp, với quyết định trust nằm giữa. Thứ tự quyết định (cờ dòng lệnh, extension, quyết định đã lưu, setting defaultProjectTrust, hộp chọn) đã được trình bày ở chương AgentSession; phần này tập trung vào cái gì được nạp ở mỗi phía của quyết định.
Chỉ những tài nguyên có thể thực thi hoặc đổi cấu hình mới đòi một quyết định. Nếu thư mục không có gì trong danh sách dưới đây, dự án được coi là tin cậy mà không hỏi (project-trust.ts:51); một thư mục .pi rỗng không đòi gì.
const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [ "settings.json", "mcp.json", "extensions", "skills", "prompts", "themes", "SYSTEM.md", "APPEND_SYSTEM.md",] as const;.agents/skills ở thư mục hiện tại hoặc một thư mục cha, trừ ~/.agents/skills của chính người dùng (trust-manager.ts:186).Khi cần quyết định, main.ts dựng services với projectTrusted: false và truyền vào resource loader một callback resolveProjectTrust (main.ts:743). reload() của resource loader khi đó chạy thành ba bước:
async loadProjectTrustExtensions(): Promise<LoadExtensionsResult> { // Force untrusted project settings for the bootstrap pass. This keeps project-local // extensions/packages out while still loading user/global and temporary CLI extensions. this.settingsManager.setProjectTrusted(false); await this.settingsManager.reload(); return this.loadCurrentExtensionSet({ includeInlineFactories: true });}async reload(options?: ResourceLoaderReloadOptions): Promise<void> { resetTimings("extensions"); if (this.loaded) { clearExtensionCache(); } let preTrustExtensions: LoadExtensionsResult | undefined; if (options?.resolveProjectTrust) { preTrustExtensions = await this.loadProjectTrustExtensions(); const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions }); this.settingsManager.setProjectTrusted(projectTrusted); } // reload() preserves SettingsManager.projectTrusted and reloads settings for that trust state. await this.settingsManager.reload();Lượt đầu nạp tập extension hiện có dưới trạng thái "không tin cậy": settings dự án không được đọc, nên chỉ còn extension của người dùng, extension truyền bằng -e và factory nội tuyến của SDK. Built-in bị hoãn có chủ đích:
private async loadCurrentExtensionSet(options: { includeInlineFactories: boolean }): Promise<LoadExtensionsResult> { const resolvedPaths = await this.packageManager.resolve(); const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { temporary: true, }); const enabledExtensions = resolvedPaths.extensions.filter((r) => r.enabled).map((r) => r.path); const cliEnabledExtensions = cliExtensionPaths.extensions.filter((r) => r.enabled).map((r) => r.path); // Built-in extensions wait for the final pass: project settings can disable them, and a loaded // extension cannot be unloaded. const extensionPaths = ( this.noExtensions ? cliEnabledExtensions : this.mergePaths(cliEnabledExtensions, enabledExtensions) ).filter((path) => !path.startsWith(BUILTIN_PATH_PREFIX)); const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus); mergeExtensionWarnings(extensionsResult, packageWarnings); if (!options.includeInlineFactories) { return extensionsResult; } const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); extensionsResult.extensions.push(...inlineExtensions.extensions); extensionsResult.errors.push(...inlineExtensions.errors); const replacementWarnings: Array<{ path: string; warning: string }> = []; extensionsResult.extensions = omitReplacedExtensions(extensionsResult.extensions, replacementWarnings); mergeExtensionWarnings(extensionsResult, replacementWarnings); return extensionsResult;project_trust.Các extension của lượt đầu nhận sự kiện project_trust. Cách gộp kết quả khác mọi sự kiện khác: handler đầu tiên trả "yes" hoặc "no" quyết định luôn, "undecided" nhường cho handler sau, và lỗi của một handler không chặn các handler còn lại:
export async function emitProjectTrustEvent( extensionsResult: LoadExtensionsResult, event: ProjectTrustEvent, ctx: ProjectTrustContext,): Promise<{ result?: ProjectTrustEventResult; errors: ExtensionError[] }> { const errors: ExtensionError[] = []; for (const { ext, handlers } of snapshotEventHandlers(extensionsResult.extensions, "project_trust")) { // A single extension may register multiple handlers for the same event. // The first project_trust handler that returns yes/no wins; undecided falls through. for (const handler of handlers) { try { const handlerResult = (await handler(event, ctx)) as ProjectTrustEventResult; if (handlerResult.trusted === "undecided") { continue; } return { result: handlerResult, errors }; } catch (error) { errors.push({ extensionPath: ext.path, event: event.type, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); } } } return { errors };}remember: true thì được ghi vào ~/.pi/agent/trust.json (project-trust.ts:55). Ví dụ project-trust.ts cho thấy một extension hỏi người dùng bằng UI riêng thay cho hộp chọn mặc định (project-trust.ts:26).Sau quyết định, settings được đọc lại theo trạng thái trust mới, packageManager.resolve() chạy lần nữa (giờ có thể thấy .pi/extensions, gói dự án, mục -builtin: của dự án), và lượt cuối chỉ nạp phần còn thiếu:
private async loadFinalExtensionSet( extensionPaths: string[], preTrustExtensions: LoadExtensionsResult | undefined,): Promise<LoadExtensionsResult> { // Without a pre-trust pass nothing is preloaded, and inline extensions load here. const preloaded = preTrustExtensions?.extensions ?? []; const preloadedByPath = new Map( preloaded .filter((extension) => !extension.path.startsWith("<inline:")) .map((extension) => [extension.resolvedPath, extension]), ); const failedPreloadPaths = new Set( (preTrustExtensions?.errors ?? []).map((error) => this.resolveExtensionLoadPath(error.path)), ); const remainingPaths = extensionPaths.filter((path) => { const resolvedPath = this.resolveExtensionLoadPath(path); return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath); }); const remainingExtensions = await this.loadExtensionPaths(remainingPaths, preTrustExtensions?.runtime); const loadedByPath = new Map(preloadedByPath); for (const extension of remainingExtensions.extensions) { loadedByPath.set(extension.resolvedPath, extension); } const inlineExtensions = preTrustExtensions ? { extensions: preloaded.filter((extension) => extension.path.startsWith("<inline:")), errors: [] } : await this.loadExtensionFactories(remainingExtensions.runtime); const orderedExtensions = extensionPaths .map((path) => loadedByPath.get(this.resolveExtensionLoadPath(path))) .filter((extension): extension is Extension => extension !== undefined); orderedExtensions.push(...inlineExtensions.extensions);Extension được giữ nguyên, kể cả các handler nó đã đăng ký. Một đường dẫn đã lỗi ở lượt đầu không được thử lại. Phần mới nạp dùng chung runtime của lượt đầu, nên mọi extension thấy cùng giá trị cờ và cùng sổ MCP.Tổng hợp lại, theo code v1.4.0:
| Thời điểm | Được nạp |
|---|---|
| Trước quyết định trust | Settings toàn cục; extension từ -e, từ ~/.pi/agent/extensions/, từ mục extensions và packages trong settings người dùng (gói người dùng còn thiếu được cài luôn); factory nội tuyến của SDK. Riêng setting sessionDir của dự án được đọc còn sớm hơn, lúc chọn session (docs/security.md:31). |
| Sau quyết định, chỉ khi tin cậy | .pi/settings.json, gói dự án (cài nếu thiếu, vào .pi/npm, .pi/git), .pi/extensions/, .pi/skills/, .agents/skills của dự án, .pi/prompts/, .pi/themes/, .pi/SYSTEM.md, .pi/APPEND_SYSTEM.md, .pi/mcp.json. |
| Sau quyết định, dù tin cậy hay không | Extension dựng sẵn builtin:*; skill, prompt template, theme của người dùng; context file AGENTS.md/CLAUDE.md từ cwd và các thư mục cha (resource-loader.ts:646). |
Điểm cuối hay gây bất ngờ: chối trust không ngăn AGENTS.md của repo lạ vào system prompt, và tài liệu nói rõ phải coi nội dung trong thư mục là đầu vào không đáng tin (docs/security.md:57). Extension đọc trạng thái hiện hành bằng ctx.isProjectTrusted(); ví dụ subagent/ dùng nó để hỏi thêm trước khi chạy agent định nghĩa trong repo chưa tin cậy (mục ví dụ). Một lần /reload giữ nguyên quyết định đã có, không hỏi lại: AgentSession.reload() gọi resourceLoader.reload() không kèm callback trust.
Bề mặt API: factory và các hàm register#
Toàn bộ hợp đồng nằm trong packages/coding-agent/src/core/extensions/types.ts, khoảng 2.300 dòng, và tài liệu chính thức cũng bảo người viết extension đọc thẳng các khai báo đó để biết payload chính xác (docs/extensions.md:188). Điểm vào là một kiểu hàm một dòng:
/** Extension factory function type. Supports both sync and async initialization. */export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;ExtensionAPI (dòng 1592 đến 1918) gom thành các nhóm sau:
| Nhóm | Thành viên | Ghi chú |
|---|---|---|
| Sự kiện | on(event, handler) | 41 overload, mỗi sự kiện một kiểu kết quả; trả hàm huỷ đăng ký. |
| Tool | registerTool | Tool mô hình gọi được; xem ToolDefinition bên dưới. |
| Lệnh, phím, cờ | registerCommand, registerShortcut, registerFlag, getFlag | Lệnh /tên; phím tắt TUI; cờ dòng lệnh --tên. |
| Vẽ | registerMessageRenderer, registerEntryRenderer, registerToolRenderer, registerMarkdownTransformer | Chỉ ảnh hưởng hiển thị, không ảnh hưởng ngữ cảnh mô hình. |
| Hành động lên phiên | sendMessage, sendUserMessage, appendEntry, setSessionName, getSessionName, setLabel | Xem mục kế tiếp. |
| Tool và cấu hình | exec, getActiveTools, setActiveTools, getAllTools, getSettings, getCommands | setActiveTools đổi tập tool khai báo cho mô hình. |
| Mô hình | setModel, getThinkingLevel, setThinkingLevel | Đổi cho phiên hiện tại, không đổi mặc định cho phiên mới. |
| Provider | registerProvider (hai dạng), unregisterProvider | Mục provider. |
| MCP | registerMcpServer, unregisterMcpServer, getMcpServers | Cấu hình giống một mục trong mcp.json; phần kết nối thuộc chương Codemode. |
| Virtual model | registerVirtualModel, unregisterVirtualModel | Một mục chọn được trong danh mục, định tuyến từng yêu cầu tới mô hình thật (chương Pi 1.0). |
| Liên lạc | events | Event bus dùng chung giữa các extension. |
Phần đầu của interface cho thấy hai mẫu lặp lại suốt API: handler nhận (event, ctx) và có thể trả về một kết quả có kiểu riêng của sự kiện đó:
/** Handler function type for events */// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statementsexport type ExtensionHandler<E, R = undefined> = (event: E, ctx: ExtensionContext) => Promise<R | void> | R | void;/** * ExtensionAPI passed to extension factory functions. */export interface ExtensionAPI { // ========================================================================= // Event Subscription // ========================================================================= on(event: "project_trust", handler: ProjectTrustHandler): () => void; on( event: "resources_discover", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>, ): () => void; on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): () => void; on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): () => void; on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): () => void; on(event: "user_bash", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): () => void; on(event: "input", handler: ExtensionHandler<InputEvent, InputEventResult>): () => void; // ========================================================================= // Tool Registration // ========================================================================= /** Register a tool that the LLM can call. */ registerTool<TParams extends TSchema = TSchema, TDetails = unknown, TState = any>( tool: ToolDefinition<TParams, TDetails, TState>, ): void;project_trust là ngoại lệ: handler của nó nhận một ProjectTrustContext rút gọn (cwd, mode, hasUI và bốn hàm UI) vì lúc đó chưa có phiên nào (types.ts:712).ToolDefinition: một tool có những gì
export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = unknown, TState = any> { /** Tool name (used in LLM tool calls) */ name: string; /** Human-readable label for UI */ label: string; /** Description for LLM */ description: string; /** Optional one-line snippet for the Available tools section in the default system prompt. Custom tools are omitted from that section when this is not provided. */ promptSnippet?: string; /** Optional guideline bullets appended to the default system prompt Guidelines section when this tool is active. */ promptGuidelines?: string[]; /** Parameter schema (TypeBox) */ parameters: TParams; /** Optional provider-side constrained sampling request for this tool. Set false to explicitly disable it, equivalent to leaving it undefined. */ constrainedSampling?: false | ConstrainedSamplingConfig; /** Controls whether ToolExecutionComponent renders the standard colored shell or the tool renders its own framing. */ renderShell?: "default" | "self"; /** Optional compatibility shim to prepare raw tool call arguments before schema validation. Must return an object conforming to TParams. */ prepareArguments?: (args: unknown) => Static<TParams>; /** * Per-tool execution mode override. * - "sequential": this tool must execute one at a time with other tool calls. * - "parallel": this tool can execute concurrently with other tool calls. * * If omitted, the default execution mode applies. */ executionMode?: ToolExecutionMode; /** Execute the tool. */ execute( toolCallId: string, params: Static<TParams>, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback<TDetails> | undefined, ctx: ExtensionToolContext, ): Promise<AgentToolResult<TDetails>>; /** Custom rendering for tool call display */ renderCall?: (args: Static<TParams>, theme: Theme, context: ToolRenderContext<TState, Static<TParams>>) => Component; /** Custom rendering for tool result display */ renderResult?: ( result: AgentToolResult<TDetails>, options: ToolRenderResultOptions, theme: Theme, context: ToolRenderContext<TState, Static<TParams>>, ) => Component;}outputSchema, exposure, namespace, reachesTools, annotations, defaultActive, prepareLoadout; chúng được bàn ở mục tool exposure.Mỗi trường gắn với một phần của Pi. description và parameters (schema TypeBox) là phần khai báo gửi provider. promptSnippet là một dòng trong mục "Available tools" của system prompt mặc định; không có nó, tool của extension không xuất hiện trong mục đó, dù mô hình vẫn thấy khai báo. promptGuidelines là các gạch đầu dòng chỉ được thêm vào phần quy tắc khi tool đang bật. prepareArguments chạy trước kiểm tra schema, dùng để chấp nhận dạng tham số cũ (agent-loop.ts:704). executionMode: "sequential" buộc cả lô lời gọi của một message chạy tuần tự nếu có một tool như thế (agent-loop.ts:525); ví dụ tic-tac-toe.ts dùng nó để hai lời gọi không giành nhau con trỏ bàn cờ.
Kết quả của execute() là AgentToolResult của pi-agent-core: content cho mô hình, details cho UI và cho việc dựng lại trạng thái, cùng các trường tuỳ chọn structuredContent, usage, isError, terminate (types.ts:424). Tách content khỏi details là quyết định có từ bài viết đầu tiên của Mario: mô hình cần văn bản gọn, còn UI cần dữ liệu có cấu trúc. Ném lỗi trong execute() thì vòng lặp tạo kết quả lỗi với thông điệp của lỗi (agent-loop.ts:856); muốn báo lỗi mà vẫn giữ details thì trả isError: true.
Đăng ký thì nhẹ: loader chỉ kiểm tra hình dạng tối thiểu rồi ghi vào Map của extension.
registerTool(tool: ToolDefinition): void { assertActive(); if (typeof tool.parameters !== "object" || tool.parameters === null || Array.isArray(tool.parameters)) { throw new Error( `Tool "${tool.name}" registered by extension "${extension.path}" must define an object parameter schema.`, ); } extension.tools.set(tool.name, { definition: tool, sourceInfo: extension.sourceInfo, }); runtime.refreshTools();},registerCommand(name: string, options: Omit<RegisteredCommand, "name" | "sourceInfo">): void { assertActive(); if (typeof name !== "string" || name.length === 0) { throw new Error( `Command registered by extension "${extension.path}" must have a non-empty string name. Use pi.registerCommand("name", { description, handler }).`, ); } if (typeof options?.handler !== "function") { throw new Error(`Command "/${name}" registered by extension "${extension.path}" must define handler().`); } extension.commands.set(name, { name, sourceInfo: extension.sourceInfo, ...options, });},runtime.refreshTools() là no-op trong lúc nạp; sau khi bind, nó dựng lại sổ tool ngay, nên một tool đăng ký từ session_start hay từ một lệnh có hiệu lực ở yêu cầu kế tiếp (ví dụ dynamic-tools.ts).Lệnh, cờ và renderer
export interface RegisteredCommand { name: string; sourceInfo: SourceInfo; description?: string; getArgumentCompletions?: (argumentPrefix: string) => AutocompleteItem[] | null | Promise<AutocompleteItem[] | null>; handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;}export interface ResolvedCommand extends RegisteredCommand { invocationName: string;}Lệnh được thực thi trước cả sự kiện input và ngay cả khi agent đang chạy (chương AgentSession, bước 1). Hai extension đăng ký cùng tên lệnh không lỗi: runner đặt tên gọi thành /tên:1, /tên:2 theo thứ tự nạp (runner.ts:814). getArgumentCompletions cấp gợi ý tham số cho trình soạn thảo.
registerFlag(name, { type, default }) khai báo một cờ dòng lệnh. Bộ phân tích tham số của CLI chạy trước khi extension được nạp, nên nó cất mọi cờ --x lạ vào unknownFlags (args.ts:249); sau khi nạp, applyExtensionFlagValues() đối chiếu chúng với các cờ đã đăng ký và báo "Unknown option" cho phần còn lại (agent-session-services.ts:82). Hệ quả của cách làm hai bước này nằm ở mục cạm bẫy.
Bốn hàm vẽ chỉ ảnh hưởng hiển thị: transcript của interactive mode, và với renderer của tool thì cả bản xuất HTML (docs/extensions.md:305). registerMessageRenderer(customType, fn) vẽ custom message (thứ sendMessage tạo ra), registerEntryRenderer vẽ custom entry (thứ appendEntry tạo ra, vốn không vào ngữ cảnh mô hình), registerMarkdownTransformer biến đổi Markdown của user và assistant trước khi vẽ. registerToolRenderer(resolver) đáng chú ý hơn: resolver được gọi cho mọi tên tool, kể cả tool chưa đăng ký (tool MCP trong một session vừa mở lại khi máy chủ chưa kết nối), và nhận next() để hỏi các resolver sau nó; viết next() ?? mine nghĩa là "chỉ lấp chỗ trống" (runner.ts:796). Component trả về là component của pi-tui (chương pi-tui).
Cuối cùng là pi.events, một EventEmitter bọc lại: lỗi trong handler bị bắt và in ra console thay vì làm hỏng bên phát (event-bus.ts:12). Bus thuộc về resource loader nên sống qua các lần /reload; vì vậy runtime theo dõi các đăng ký của mình và gỡ chúng khi runtime bị vô hiệu, sau /reload hay khi thay phiên (loader.ts:196); không có cơ chế này, mỗi lần nạp lại sẽ để lại một listener mồ côi.
Context, UI và các lời gọi tới session#
Handler cần biết mình đang ở đâu: thư mục nào, mô hình nào, có hỏi được người dùng không. Nhưng cùng một extension chạy trong cả bốn chế độ (TUI, RPC, JSON, print), và phiên có thể bị thay giữa chừng (/new, /resume, /fork, /reload). Vì thế ctx không phải một ảnh chụp mà là một khung nhìn sống, mỗi lần đọc lại hỏi runner.
export type ExtensionMode = "tui" | "rpc" | "json" | "print";export interface ExtensionContext { /** UI methods for user interaction */ ui: ExtensionUIContext; /** Current run mode. Use "tui" to guard terminal-only UI such as custom components. */ mode: ExtensionMode; /** Whether dialog-capable UI is available (true in TUI and RPC modes) */ hasUI: boolean; /** Current working directory */ cwd: string; /** Session manager (read-only) */ sessionManager: ReadonlySessionManager; /** Model registry for API key resolution */ modelRegistry: ModelRegistry; /** Current model (may be undefined) */ model: Model<any> | undefined; /** Models scoped to this session (resolved from `--models` / * `enabledModels` settings against the available catalogue). Same set * the `/scoped-models` command shows. Empty when no scoping is * configured (all available models are usable). Read-only snapshot. */ scopedModels: readonly ScopedModel[]; /** Current thinking level, when provided by the session runtime. */ thinkingLevel?: ThinkingLevel; /** Whether the agent is idle (not streaming) */ isIdle(): boolean; /** Whether project-local trust is active for this context. */ isProjectTrusted(): boolean; /** The current abort signal, or undefined when the agent is not streaming. */ signal: AbortSignal | undefined; /** Abort the current agent operation */ abort(): void; /** Whether there are queued messages waiting */ hasPendingMessages(): boolean; /** Gracefully shutdown pi and exit. Available in all contexts. */ shutdown(): void; /** Get current context usage for the active model. */ getContextUsage(): ContextUsage | undefined; /** Trigger compaction without awaiting completion. */ compact(options?: CompactOptions): void; /** Get the current effective system prompt. */ getSystemPrompt(): string;}sessionManager là kiểu ReadonlySessionManager, chỉ gồm các hàm đọc (session-manager.ts:245); muốn ghi thì đi qua pi.appendEntry() và các hàm hành động khác. modelRegistry.streamSimple() là cách gọi mô hình lồng nhau không phụ thuộc provider./** * Create an ExtensionContext for use in event handlers and tool execution. * Context values are resolved at call time, so changes via bindCore/bindUI are reflected. */createContext(): ExtensionContext { const runner = this; const getModel = this.getModel; const getScopedModels = this.getScopedModels; return { get ui() { runner.assertActive(); return runner.uiContext; }, get mode() { runner.assertActive(); return runner.mode; }, get hasUI() { runner.assertActive(); return runner.hasUI(); }, get cwd() { runner.assertActive(); return runner.cwd; }, get sessionManager() { runner.assertActive(); return runner.sessionManager; }, get modelRegistry() { runner.assertActive(); return runner.modelRegistry; },withSession. Command context được dựng bằng Object.defineProperties thay vì spread, vì spread sẽ đọc các getter một lần và đóng băng giá trị cũ, bỏ qua kiểm tra stale (runner.ts:997).UI: một interface, bốn cách hiện thực
export interface ExtensionUIContext { /** Show a selector and return the user's choice. */ select(title: string, options: string[], opts?: ExtensionUIDialogOptions): Promise<string | undefined>; /** Show a confirmation dialog. */ confirm(title: string, message: string, opts?: ExtensionUIDialogOptions): Promise<boolean>; /** Show a text input dialog. */ input(title: string, placeholder?: string, opts?: ExtensionUIDialogOptions): Promise<string | undefined>; /** Show a notification to the user. */ notify(message: string, type?: "info" | "warning" | "error"): void; /** Listen to raw terminal input (interactive mode only). Returns an unsubscribe function. */ onTerminalInput(handler: TerminalInputHandler): () => void; /** Set status text in the footer/status bar. Pass undefined to clear. */ setStatus(key: string, text: string | undefined): void; /** Set a widget to display above or below the editor. Accepts string array or component factory. */ setWidget(key: string, content: string[] | undefined, options?: ExtensionWidgetOptions): void; setWidget( key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined, options?: ExtensionWidgetOptions, ): void; /** Show a custom component with keyboard focus. */ custom<T>( factory: ( tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: T) => void, ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>, options?: { overlay?: boolean; /** Overlay positioning/sizing options. Can be static or a function for dynamic updates. */ overlayOptions?: OverlayOptions | (() => OverlayOptions); /** Called with the overlay handle after the overlay is shown. Use to control visibility. */ onHandle?: (handle: OverlayHandle) => void; }, ): Promise<T>;setFooter, setHeader, setTitle, setEditorText/getEditorText, editor(), setEditorComponent (thay hẳn trình soạn thảo, ví dụ modal-editor.ts), addAutocompleteProvider, và các hàm theme.Mỗi chế độ cấp một hiện thực riêng. Interactive mode cấp UI đầy đủ trên pi-tui. RPC mode biến các hộp thoại (select, confirm, input, editor) thành cặp extension_ui_request/extension_ui_response qua stdin/stdout, còn custom() trả undefined và các hàm thuần TUI là no-op (docs/rpc-extension-ui.md:5). Print và JSON không có UI: runner dùng một đối tượng mà confirm() trả false, select() trả undefined (runner.ts:331). Hai cờ phân biệt các trường hợp: ctx.hasUI đúng cho TUI và RPC (hộp thoại dùng được), ctx.mode === "tui" cho thứ cần terminal thật (docs/extensions.md:363). Một extension an toàn phải có nhánh cho trường hợp không có UI, như permission-gate.ts chặn luôn lệnh nguy hiểm khi không hỏi được ai.
Runner còn bọc các hộp thoại để phát ui_prompt_start và ui_prompt_end quanh hộp thoại ngoài cùng (runner.ts:577). Một extension thông báo trên desktop có thể nghe chúng để báo "Pi đang chờ bạn" mà không cần biết extension nào mở hộp thoại.
Command context và thay phiên an toàn
export interface ExtensionCommandContext extends ExtensionContext { /** Get the current base system-prompt construction options. */ getSystemPromptOptions(): BuildSystemPromptOptions; /** Wait for the agent to finish streaming */ waitForIdle(): Promise<void>; /** Start a new session, optionally with initialization. */ newSession(options?: { parentSession?: string; setup?: (sessionManager: SessionManager) => Promise<void>; withSession?: (ctx: ReplacedSessionContext) => Promise<void>; }): Promise<{ cancelled: boolean }>; /** Fork from a specific entry, creating a new session file. */ fork( entryId: string, options?: { position?: "before" | "at"; withSession?: (ctx: ReplacedSessionContext) => Promise<void> }, ): Promise<{ cancelled: boolean }>; /** Navigate to a different point in the session tree. */ navigateTree( targetId: string, options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }, ): Promise<{ cancelled: boolean }>; /** Switch to a different session file. */ switchSession( sessionPath: string, options?: { withSession?: (ctx: ReplacedSessionContext) => Promise<void> }, ): Promise<{ cancelled: boolean }>; /** Reload extensions, skills, prompts, themes, and context files. */ reload(): Promise<void>;}export interface ReplacedSessionContext extends ExtensionCommandContext { sendMessage<T = unknown>( message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ): Promise<void>; sendUserMessage( content: string | (TextContent | ImageContent)[], options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean }, ): Promise<void>;}turn_end giữ lại command ctx từ trước để gọi waitForIdle(), nó chờ lần chạy kết thúc, trong khi lần chạy đang chờ chính handler đó.Mẫu dùng đúng: trước khi thay phiên chỉ giữ dữ liệu thuần (chuỗi, số), rồi làm mọi việc gắn với phiên mới trong withSession, với ctx mới được truyền vào. Ctx cũ sau đó ném lỗi. Với reload() không có withSession: mã sau await ctx.reload() vẫn thuộc runtime cũ và không được dùng lại trạng thái của nó (docs/extensions.md:145). Tool thì nhận ExtensionToolContext, tức ctx thường cộng tools và executeTool() để gọi tool khác qua cùng các hook (types.ts:383); đây là nền của codemode, thuộc chương Codemode.
Các hành động lên phiên
Khi AgentSession bind runner, các stub được thay bằng hàm thật. Phần lớn chỉ là một dòng chuyển tiếp:
runner.bindCore( { sendMessage: (message, options) => { this.sendCustomMessage(message, options).catch((err) => { runner.emitError({ extensionPath: "<runtime>", event: "send_message", error: err instanceof Error ? err.message : String(err), }); }); }, sendUserMessage: (content, options) => { this.sendUserMessage(content, options).catch((err) => { runner.emitError({ extensionPath: "<runtime>", event: "send_user_message", error: err instanceof Error ? err.message : String(err), }); }); }, appendEntry: (customType, data) => { const entryId = this.sessionManager.appendCustomEntry(customType, data); const entry = this.sessionManager.getEntry(entryId); if (entry) { this._emit({ type: "entry_appended", entry }); } getActiveTools: () => this.getActiveToolNames(), getAllTools: () => this.getAllTools(), getSettings: () => this.settingsManager.getSettings(), setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames), refreshTools: () => this._refreshToolRegistry(), getCommands, setModel: async (model) => { if (!this._modelRuntime.hasConfiguredAuth(model.provider)) return false; await this.setModel(model); return true; },sendMessage và sendUserMessage là "bắn rồi quên" (fire-and-forget): lỗi không ném về extension mà được báo qua emitError. setModel trả false nếu provider chưa có credential.appendEntry(customType, data) ghi ngay một entry custom vào session. Entry này không bao giờ vào ngữ cảnh mô hình (session-manager.ts:117); nó tồn tại để extension cất trạng thái và tìm lại khi phiên mở lại. sendMessage() thì tạo một message role custom có vào ngữ cảnh (dưới dạng user sau convertToLlm), và cách nó được giao phụ thuộc trạng thái của agent:
async sendCustomMessage<T = unknown>( message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },): Promise<void> { if (options?.deliverAs === "nextTurn") { this._pendingNextTurnMessages.push(appMessage); } else if (this.isStreaming && options?.triggerTurn !== false) { if (options?.deliverAs === "followUp") { this.agent.followUp(appMessage); } else { this.agent.steer(appMessage); } } else if (options?.triggerTurn) { if (this._isEmittingAgentSettled) { this._deferredSettledActions.push(async () => await this.sendCustomMessage(message, options)); return; } await this._startRun({ messageType: "custom", message: appMessage }); } else if (this.isStreaming) { // Appending now would put the message between an assistant tool call and its // result, which providers that validate message order reject on replay. Defer // to the end of the turn. Nothing is emitted yet: message events must not // describe messages the session tree does not contain. this._pendingCustomMessages.push(appMessage); } else { this._appendCustomMessage(appMessage); }}triggerTurn, message được steer vào lần chạy hiện tại. Chỉ triggerTurn: false mới hoãn nó tới cuối lượt mà không xin thêm lượt nào.Tài liệu chốt bằng một bảng chọn nơi lưu trạng thái (docs/extensions.md:339):
| Trạng thái | Cất ở | Vì sao |
|---|---|---|
| Trạng thái của tool, phải đi theo nhánh đang hoạt động | details của kết quả tool | Fork hay /tree về một điểm cũ thì kết quả tool cũ chính là trạng thái lúc đó. |
| Dữ liệu bền, không cho mô hình thấy | pi.appendEntry() | Entry custom, bị bỏ qua khi dựng ngữ cảnh. |
| Nội dung cần cho mô hình thấy | pi.sendMessage() | Entry custom_message, vào ngữ cảnh. |
| Dữ liệu vượt ra ngoài một phiên | Lưu trữ ngoài | Tệp, cơ sở dữ liệu; Pi không quản lý. |
Và quy tắc đi kèm: dựng lại trạng thái từ ctx.sessionManager.getBranch() (đường từ gốc tới lá hiện tại) trong session_start, không phải từ mọi entry trong tệp, vì các nhánh bị bỏ là những lịch sử khác. Hai hành động còn lại: setActiveTools(names) đổi tập tool khai báo cho mô hình từ yêu cầu kế tiếp, bỏ qua tên chưa đăng ký hoặc hidden, và Pi ghi thay đổi đó vào transcript dưới dạng một message system chứa bản vá (docs/extensions.md:299; cơ chế ở chương AgentSession). exec(command, args, options) chạy một tiến trình con không qua shell, gom stdout/stderr, hỗ trợ timeout và signal (cả hai gửi SIGTERM), và không bao giờ reject: lỗi spawn trả về code: 1 (exec.ts:34).
Tool exposure, namespace và prepareLoadout#
Vấn đề: một vài máy chủ MCP có thể đem tới hàng trăm tool. Khai báo hết cho mô hình tốn token ở mọi yêu cầu, và mỗi lần tập tool đổi thì tiền tố prompt cache bị phá (Earendil, "Prompt Caching In Agents"). Từ bản 0.99.0, ngay trước 1.0 (CHANGELOG.md:441), Pi tách hai khái niệm: một tool có thể được khai báo cho mô hình, và có thể gọi được từ tool khác qua ctx.executeTool(). Trường exposure của ToolDefinition chọn tổ hợp:
/** * 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";exposure | Khai báo cho mô hình | Gọi được qua executeTool | Bật khi đăng ký |
|---|---|---|---|
direct (mặc định) | khi đang bật | khi đang bật | có |
model-only | khi đang bật | không bao giờ | có |
codemode | chỉ khi bật tường minh | luôn, và được tool codemode liệt kê | không |
deferred | chỉ khi bật tường minh | luôn, nhưng chỉ tool_search tìm ra | không |
hidden | không | không | không |
Tập "đang bật" (active) của getActiveTools/setActiveTools chính là tập khai báo cho mô hình. Tập gọi được thì tính riêng:
/** * Tools callable through `ctx.executeTool()`: the active `direct` tools and every registered * `codemode` or `deferred` tool. */private _getCallableTools(active: ReadonlySet<string> = new Set(this.getActiveToolNames())): AgentTool[] { return [...this._toolRegistry.values()].filter((tool) => { const exposure = this._getToolExposure(tool.name); return exposure === "codemode" || exposure === "deferred" || (exposure === "direct" && active.has(tool.name)); });}hidden tồn tại vì tool không gỡ đăng ký được: muốn rút một tool thì đăng ký lại cùng tên với exposure: "hidden" (docs/extensions.md:273). Ba trường khác bổ sung siêu dữ liệu. namespace gom các tool liên quan, như các tool của một máy chủ MCP, để tool codemode liệt kê chúng dưới một tiêu đề, còn instructions dài hơn chỉ được trả khi script hỏi describeNamespace(). annotations mang các gợi ý kiểu MCP (readOnlyHint, destructiveHint, idempotentHint, openWorldHint); chúng không được kiểm chứng, nhưng một extension cấp quyền có thể dựa vào chúng để quyết định lời gọi nào cần xác nhận, và tài liệu có sẵn đoạn handler tool_call làm đúng như vậy (docs/extensions.md:279). reachesTools(input) cho biết một lời gọi của tool điều phối có thể chạm tới những tool nào, để extension đăng ký tool muộn (MCP đang kết nối) kịp hoàn tất trong handler tool_call trước khi lời gọi chạy.
Mảnh cuối là prepareLoadout: một tool điều phối (như codemode) được xem toàn bộ loadout mỗi khi tập tool đổi, và trả lại mô tả mới cho các tool được khai báo cùng danh sách hiddenDeclarations, những tool vẫn bật và gọi được nhưng không được gửi khai báo trong yêu cầu:
/** 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[];}AgentSession._applyToolLoadout() gọi hook này, bắt lỗi của nó thành lỗi extension, rồi đặt agent.state.tools bằng các khai báo đã đổi mô tả (agent-session.ts:1595). Tài liệu nhấn mạnh codemode chỉ dùng hook này cùng exposure, reachesTools và executeTool, nên một extension khác có thể làm lại hành vi đó dưới một tên khác (docs/extensions.md:293). Phần ruột thuộc chương Codemode.Danh mục sự kiện và cách runner phát chúng#
Union ExtensionEvent có 32 nhánh, một số nhánh là union con (mười sự kiện session, chín biến thể tool_call) (types.ts:1404). Đọc từng cái một không giúp nhiều; dễ nhớ hơn là đặt chúng lên dòng thời gian của một prompt. Ví dụ minh hoạ dưới đây theo đúng thứ tự trong code khi người dùng gõ một câu và mô hình gọi một tool rồi trả lời:
Ví dụ minh hoạ: các sự kiện extension quanh một prompt người dùng gõ "sửa test hỏng" ⏎ → AgentSession.prompt() ├─ (/tên của extension? chạy handler lệnh rồi dừng) ├─ input chuỗi biến đổi; "handled" nuốt input ├─ (mở rộng /skill:tên, prompt template) └─ _startRun() ├─ (compaction trước prompt? session_before_compact → session_compact) ├─ before_agent_start sửa systemPromptOptions, thêm custom message ├─ agent_start │ ┌ turn_start │ │ message_start/end (system patch, user, custom) ← ghi entry │ │ context → context_with_system mỗi yêu cầu, trên bản sao │ │ before_provider_headers → before_provider_request │ │ after_provider_response │ │ provider_stream_event*, message_start, message_update*, message_end (assistant) │ │ mỗi lời gọi tool: │ │ tool_execution_start → tool_call → [execute: tool_execution_update*] │ │ → tool_result → tool_execution_end → message_start/end (toolResult) │ └ turn_end (boundary: thêm entry, xin chạy tiếp) │ ┌ turn_start … lượt 2: mô hình trả lời bằng văn bản … │ └ turn_end ├─ agent_end chưa phải lúc rảnh: còn retry, compaction, hàng đợi ├─ agent_before_settle (boundary cuối) └─ agent_settled phiên thực sự rảnh
Ngoài dòng thời gian đó còn các sự kiện của vòng đời phiên và vài sự kiện lẻ:
| Nhóm | Sự kiện | Kết quả handler |
|---|---|---|
| Khởi động | project_trust, session_start, resources_discover, mcp_servers_change | project_trust: người đầu tiên trả yes/no thắng. resources_discover (sau session_start): gom thêm đường dẫn skill, prompt, theme. |
| Thay phiên, cây, compaction | session_before_switch, session_before_fork, session_before_tree, session_before_compact; session_tree, session_compact, session_compact_failed, session_shutdown, session_info_changed | Các sự kiện before là cổng: cancel: true dừng thao tác; session_before_compact còn cấp được bản tóm tắt tự làm (chương Compaction). |
| Đầu vào | input, user_bash | input: chuỗi biến đổi. user_bash (lệnh ! của người dùng): trả operations hoặc result thì thay việc thực thi cục bộ; handler lỗi thì lệnh bị chặn. |
| Mô hình | model_select, thinking_level_select | Thông báo. |
| UI, cache | ui_prompt_start, ui_prompt_end, cache_warming_decision | cache_warming_decision: handler cuối cùng trả action thắng. |
Thứ tự, snapshot và một ctx cho mỗi lần phát
Mọi hàm phát của runner bắt đầu giống nhau: tạo một ctx, rồi duyệt một bản chụp các handler:
function snapshotEventHandlers(extensions: Extension[], event: ExtensionEvent["type"]) { return extensions.map((ext) => ({ ext, handlers: ext.handlers.get(event)?.slice() ?? [] }));}Bản chụp (slice()) nghĩa là một handler gọi hàm huỷ đăng ký trong lúc đang phát không làm hỏng vòng lặp; thay đổi chỉ có hiệu lực từ lần phát sau (docs/extensions.md:197). Các handler được await lần lượt, không song song, nên handler chậm của một sự kiện luồng như message_update làm chậm cả luồng.
Cô lập lỗi: mặc định báo rồi đi tiếp
async emit<TEvent extends RunnerEmitEvent>(event: TEvent): Promise<RunnerEmitResult<TEvent>> { const ctx = this.createContext(); let result: SessionBeforeEventResult | undefined; for (const { ext, handlers } of snapshotEventHandlers(this.extensions, event.type)) { for (const handler of handlers) { try { const handlerResult = await handler(event, ctx); if (this.isSessionBeforeEvent(event) && handlerResult) { result = handlerResult as SessionBeforeEventResult; if (result.cancel) { return result as RunnerEmitResult<TEvent>; } } } catch (err) { const message = err instanceof Error ? err.message : String(err); const stack = err instanceof Error ? err.stack : undefined; this.emitError({ extensionPath: ext.path, event: event.type, error: message, stack, }); } } } return result as RunnerEmitResult<TEvent>;}emitError chuyển lỗi cho listener mà chế độ chạy đăng ký; interactive mode hiện nó trong transcript kèm đường dẫn extension (interactive-mode.ts:2036). Với các sự kiện session_before_*, kết quả khác undefined cuối cùng được trả về, trừ khi có handler huỷ trước.Có ba ngoại lệ có chủ đích, tất cả theo hướng an toàn. emitToolCall() không bắt lỗi (mục lần theo): handler cấp quyền mà hỏng thì tool không chạy. emitUserBash() báo lỗi rồi ném tiếp, nên lệnh ! bị chặn thay vì lặng lẽ chạy cục bộ (runner.ts:1269). emitMessageEnd() từ chối một message thay thế đổi role (runner.ts:1163). Tài liệu tóm lại thành một nguyên tắc cho người viết: handler cổng phải đóng (fail closed) khi không quyết định được, handler biến đổi phải giữ nguyên các trường nó không sở hữu (docs/extensions.md:214).
Biến đổi ngữ cảnh: context và context_with_system
Ngay trước mỗi yêu cầu, vòng lặp agent gọi transformContext (agent-loop.ts:397), mà sdk.ts nối vào runner.emitContext() (sdk.ts:428). Vấn đề cần giải: ở v1.4.0 system prompt và khai báo tool nằm trong transcript dưới dạng message system (chương AgentSession). Một extension cắt bớt lịch sử, chẳng hạn chỉ giữ hai mươi message cuối, sẽ vô tình cắt luôn system prompt.
/** * Run the request-time transforms in two phases. `context` handlers see the conversation * only and Pi restores the prompt and tool state after each; `context_with_system` * handlers then see the full transcript and their output is used as returned. */async emitContext(messages: AgentMessage[]): Promise<AgentMessage[]> { const ctx = this.createContext(); let currentMessages = structuredClone(messages); for (const { ext, handlers } of snapshotEventHandlers(this.extensions, "context")) { for (const handler of handlers) { try { const visibleMessages = currentMessages.filter((message) => message.role !== "system"); const visibleSnapshot = visibleMessages.slice(); const event: ContextEvent = { type: "context", messages: visibleMessages }; const handlerResult = (await handler(event, ctx)) as ContextEventResult | undefined; // Handlers may return a new list or edit event.messages in place. const returned = handlerResult?.messages ?? (sameMessages(visibleMessages, visibleSnapshot) ? undefined : visibleMessages); if (!returned) continue; currentMessages = restoreSystemMessages(currentMessages, visibleSnapshot, returned); for (const { ext, handlers } of snapshotEventHandlers(this.extensions, "context_with_system")) { for (const handler of handlers) { try { const hadLeadingSystemMessage = currentMessages[0]?.role === "system"; const event: ContextWithSystemEvent = { type: "context_with_system", messages: currentMessages }; const handlerResult = (await handler(event, ctx)) as ContextEventResult | undefined; currentMessages = handlerResult?.messages ?? currentMessages; // Providers read the prompt and initial tools from the leading system message. // Losing it is never intended; report it but honor the handler's output. if (hadLeadingSystemMessage && currentMessages[0]?.role !== "system") { this.emitError({ extensionPath: ext.path, event: "context_with_system", error: "Handler removed the leading system message; the request has no prompt or initial tool declarations. Keep it at index 0 or replace a dropped prefix with getCurrentSystemMessage().", }); }structuredClone ở dòng 1307 cho phép handler sửa message thoải mái mà không chạm vào trạng thái agent hay tệp session: biến đổi chỉ sống trong một yêu cầu./** * Re-attach the prompt and tool state after a `context` handler. Handlers only see the * conversation; the system messages belong to Pi. An unchanged conversation keeps every * system message in place, so models with mid-conversation support keep their cached * prefix. A changed one gets the replayed prompt sections and tool declarations as one * leading system message, so pruning, windowing, or slicing from a compaction summary * cannot drop them. */function restoreSystemMessages( current: AgentMessage[], visible: AgentMessage[], returned: AgentMessage[],): AgentMessage[] { if (sameMessages(returned, visible)) return current; const head = getCurrentSystemMessage(current); return head ? [head, ...returned] : returned;}Ví dụ plan-mode/ dùng context đúng kiểu này: khi đã thoát plan mode, nó lọc bỏ các message [PLAN MODE ACTIVE] cũ để chúng không còn ràng buộc mô hình (mục ví dụ). context_with_system dành cho trường hợp hiếm khi handler phải sở hữu cả transcript; bỏ mất message system ở chỉ số 0 bị báo lỗi nhưng vẫn được tôn trọng.
Boundary: turn_end và agent_before_settle
Hai sự kiện này cho extension ghi thêm entry vào session và xin thêm đúng một yêu cầu mô hình, ở hai điểm mà transcript chắc chắn hợp lệ: cuối một lượt (sau khi mọi kết quả tool đã được ghi) và ngay trước khi phiên rảnh. Mỗi handler thấy đề xuất của các handler trước nó, cùng một bản xem trước ngữ cảnh dựng bằng cách áp các entry nháp lên một bản sao trong bộ nhớ của nhánh:
async emitBoundary( baseEvent: BoundaryBaseEvent, buildContext: (entries: SessionBoundaryDraft[]) => BoundaryContextPreview | Promise<BoundaryContextPreview>,): Promise<BoundaryDispatchResult> { const ctx = this.createContext(); let entries: SessionBoundaryDraft[] = []; let shouldContinue = false; let context = await buildContext(entries); let valid = true; for (const { ext, handlers } of snapshotEventHandlers(this.extensions, baseEvent.type)) { for (const handler of handlers) { const event = { ...baseEvent, entries, continue: shouldContinue, context, } as TurnEndEvent | AgentBeforeSettleEvent; try { const handlerResult = (await handler(event, ctx)) as BoundaryResult | undefined; if (handlerResult?.entries !== undefined) entries = handlerResult.entries; if (handlerResult?.continue !== undefined) shouldContinue = handlerResult.continue; } catch (err) { try { context = await buildContext(entries); valid = true; } catch (err) { valid = false; this.emitError({ extensionPath: ext.path, event: baseEvent.type, error: `Invalid boundary entries: ${err instanceof Error ? err.message : String(err)}`, stack: err instanceof Error ? err.stack : undefined, }); } } } return valid ? { entries, continue: shouldContinue, context, valid: true } : { entries: [], continue: false, context, valid: false };}AgentSession._buildBoundaryContext() dựng trên một SessionManager.inMemory chứa nhánh hiện tại cộng các nháp (agent-session.ts:977); nháp hỏng (ví dụ context_edit trỏ vào entry không tồn tại) làm cả lô bị bỏ và không chạy tiếp.Các nháp hợp lệ thuộc bốn loại: custom, custom_message, context_edit, compaction (types.ts:972). continue: true chỉ được chấp nhận khi ngữ cảnh chạy tiếp được, tức message cuối không phải của assistant hoặc còn message đang chờ; nếu không, Pi báo "requested continuation without runnable model context" thay vì gửi một yêu cầu vô nghĩa. Tài liệu cảnh báo phải đặt điều kiện cho việc xin chạy tiếp, vì một handler luôn trả continue: true sẽ tạo vòng lặp (docs/extensions.md:230).
Lần theo một lời gọi tool bị sửa và bị chặn#
Extension cấp quyền là ví dụ kinh điển của sự kiện tool_call. Repo có sẵn một bản tối giản: hỏi người dùng trước các lệnh bash khớp mẫu nguy hiểm, và chặn luôn khi không có UI để hỏi.
export default function (pi: ExtensionAPI) { const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i]; pi.on("tool_call", async (event, ctx) => { if (event.toolName !== "bash") return undefined; const command = event.input.command as string; const isDangerous = dangerousPatterns.some((p) => p.test(command)); if (isDangerous) { if (!ctx.hasUI) { // In non-interactive mode, block by default return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" }; } const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]); if (choice !== "Yes") { return { block: true, reason: "Blocked by user" }; } } return undefined; });}Để thấy cả hai khả năng của tool_call (chặn và sửa tham số) cùng với tool_result, kịch bản dưới đây dùng một extension viết tay. Nó không có trong repo; mọi hàm nó gọi thì có.
Ví dụ minh hoạ: ~/.pi/agent/extensions/bash-guard.ts (viết cho chương này)
import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event) => {
if (!isToolCallEventType("bash", event)) return; // thu hẹp kiểu: input là BashToolInput
if (/\brm\s+-rf\b/.test(event.input.command)) {
return { block: true, reason: "bash-guard: rm -rf bị chặn" };
}
event.input.timeout ??= 120; // sửa tại chỗ: mặc định 120 giây
});
pi.on("tool_result", async (event) => {
if (event.toolName !== "bash" || event.isError) return;
const text = event.content.map((c) => (c.type === "text" ? c.text : "")).join("");
if (!/AKIA[0-9A-Z]{16}/.test(text)) return;
return { content: [{ type: "text", text: text.replace(/AKIA[0-9A-Z]{16}/g, "[đã che]") }] };
});
}bash dựng sẵn có tham số timeout tính bằng giây, mặc định không có (bash.ts:40). isToolCallEventType cần thiết vì so sánh event.toolName === "bash" không thu hẹp được kiểu (types.ts:1385).Kịch bản: mô hình trả về một message assistant với hai lời gọi tool, call_1 là bash {"command":"npm test"} và call_2 là bash {"command":"rm -rf dist"}, stopReason: "toolUse". Message được ghi vào session ở message_end. Chế độ thực thi mặc định là song song (agent.ts:261) và bash không đòi chạy tuần tự, nên vòng lặp đi vào executeToolCallsParallel().
Bước 1: chuẩn bị tuần tự, thực thi song song
async function executeToolCallsParallel( currentContext: AgentContext, assistantMessage: AssistantMessage, toolCalls: AgentToolCall[], config: AgentLoopConfig, signal: AbortSignal | undefined, emit: AgentEventSink,): Promise<ExecutedToolCallBatch> { const finalizedCalls: FinalizedToolCallEntry[] = []; for (const toolCall of toolCalls) { await emit({ type: "tool_execution_start", toolCallId: toolCall.id, toolName: toolCall.name, args: toolCall.arguments, }); const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal); if (preparation.kind === "immediate") { const finalized = { toolCall, result: preparation.result, isError: preparation.isError, } satisfies FinalizedToolCallOutcome; await emitToolExecutionEnd(finalized, emit); finalizedCalls.push(finalized); if (signal?.aborted) { break; } continue; } finalizedCalls.push(async () => { if (signal?.aborted) { const finalized = { toolCall, result: createErrorToolResult("Operation aborted"), isError: true, } satisfies FinalizedToolCallOutcome; await emitToolExecutionEnd(finalized, emit); return finalized; } const executed = await executePreparedToolCall(preparation, signal, emitToolExecutionUpdate(toolCall, emit)); const finalized = await finalizeExecutedToolCall( currentContext, assistantMessage, preparation, executed, config, signal, ); await emitToolExecutionEnd(finalized, emit); return finalized; }); const orderedFinalizedCalls = await Promise.all( finalizedCalls.map((entry) => (typeof entry === "function" ? entry() : Promise.resolve(entry))), ); const messages: ToolResultMessage[] = []; for (const finalized of orderedFinalizedCalls) { const toolResultMessage = createToolResultMessage(finalized); await emitToolResultMessage(toolResultMessage, emit); messages.push(toolResultMessage); }for chuẩn bị từng lời gọi theo thứ tự và chờ xong mới sang lời gọi kế; chỉ phần thực thi được gói vào closure để Promise.all chạy cùng lúc. Kết quả tool được phát lại theo thứ tự gốc của các lời gọi.Điều này có hệ quả thực tế cho extension cấp quyền: các hộp thoại xác nhận của một lô hiện ra lần lượt, trước khi bất kỳ lời gọi nào trong lô chạy. Với call_1: phát tool_execution_start với args là tham số gốc của mô hình, rồi gọi prepareToolCall().
Bước 2: kiểm tra schema, rồi hook
const tool = tools.find((t) => t.name === toolCall.name);if (!tool) { return { kind: "immediate", result: createErrorToolResult(`Tool ${toolCall.name} not found`), isError: true, };}try { const preparedToolCall = prepareToolCallArguments(tool, toolCall); const validatedArgs = validateToolArguments(tool, preparedToolCall); if (config.beforeToolCall) { const beforeResult = await config.beforeToolCall( { assistantMessage, toolCall, args: validatedArgs, context: currentContext, }, signal, ); if (signal?.aborted) { return { kind: "immediate", result: createErrorToolResult("Operation aborted"), isError: true, }; } if (beforeResult?.block) { const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked"); if (beforeResult.terminate === true) { result.terminate = true; } return { kind: "immediate", result, isError: true, }; } } return { kind: "prepared", toolCall, tool, args: validatedArgs, };} catch (error) { return { kind: "immediate", result: createErrorToolResult(error instanceof Error ? error.message : String(error)), isError: true, };}currentContext.tools, tức tập tool đang bật (active); tool codemode/deferred chưa bật thì mô hình gọi trực tiếp sẽ nhận "Tool X not found".Tham số được kiểm tra trước khi hook chạy, và validateToolArguments() làm việc trên một bản sao:
export function validateToolArguments(tool: Tool, toolCall: ToolCall): any { if (toolCall.argumentsError) { throw new Error(`Invalid arguments for tool "${toolCall.name}": ${toolCall.argumentsError}`); } const args = structuredClone(toolCall.arguments); normalizeOptionalNulls(args, tool.parameters as JsonSchemaObject); Value.Convert(tool.parameters, args);Hook beforeToolCall của Agent do AgentSession cài một lần trong constructor:
private _installAgentToolHooks(): void { this.agent.beforeToolCall = (context) => this._beforeToolCall(context); this.agent.afterToolCall = (context) => this._afterToolCall(context);}/** `tool_call` handlers. `parentToolCallId` is set for calls another tool made. */private async _beforeToolCall( { toolCall, args }: BeforeToolCallContext, parentToolCallId?: string,): Promise<BeforeToolCallResult | undefined> { const runner = this._extensionRunner; if (!runner.hasHandlers("tool_call")) { return undefined; } try { return await runner.emitToolCall({ type: "tool_call", toolName: toolCall.name, toolCallId: toolCall.id, ...(parentToolCallId ? { parentToolCallId } : {}), input: args as Record<string, unknown>, }); } catch (err) { if (err instanceof Error) { throw err; } throw new Error(`Extension failed, blocking execution: ${String(err)}`); }}this._extensionRunner lúc chạy, nên sau /reload runner mới tự động được dùng. Lời gọi lồng qua ctx.executeTool() đi qua đúng hàm này với parentToolCallId (agent-session.ts:742), nên bash-guard cũng canh được các lời gọi do script codemode phát ra.async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> { const ctx = this.createContext(); let result: ToolCallEventResult | undefined; for (const { handlers } of snapshotEventHandlers(this.extensions, "tool_call")) { for (const handler of handlers) { const handlerResult = await handler(event, ctx); if (handlerResult) { result = handlerResult as ToolCallEventResult; if (result.block) { return result; } } } } return result;}Với call_1, bash-guard không chặn mà gán event.input.timeout = 120. Vì event.input là chính đối tượng validatedArgs, và mọi handler nhận cùng một event, các handler sau thấy timeout mới, và đối tượng đó được đặt vào args của lời gọi đã chuẩn bị (dòng 778). Không có bước kiểm tra schema thứ hai (types.ts:1250): một handler gán timeout = -1 thì chính tool bash sẽ ném "Invalid timeout" lúc thực thi.
Với call_2, handler trả { block: true, reason }. emitToolCall() trả ngay, không gọi các handler sau, và vòng lặp biến lý do thành một kết quả lỗi (dòng 755 đến 756): content là văn bản "bash-guard: rm -rf bị chặn", details: {}. Kết quả "immediate" này được phát tool_execution_end ngay (dòng 621), trước cả khi npm test bắt đầu.
Bước 3: thực thi, rồi tool_result
Promise.all gọi closure của call_1. executePreparedToolCall() gọi tool.execute(id, args, signal, onUpdate) (agent-loop.ts:831); vì tool đã được bọc bởi wrapToolDefinition, định nghĩa bash nhận thêm ctx từ runner.createToolContext(). Lệnh chạy với timeout: 120; output được gửi dần qua các tool_execution_update, có giới hạn tần suất (bash.ts:303). Xong thì finalizeExecutedToolCall() gọi hook afterToolCall (agent-loop.ts:869):
private async _afterToolCall( { toolCall, args, result, isError }: AfterToolCallContext, parentToolCallId?: string,): Promise<AfterToolCallResult | undefined> { const runner = this._extensionRunner; const hookResult = runner.hasHandlers("tool_result") ? await runner.emitToolResult({ type: "tool_result", toolName: toolCall.name, toolCallId: toolCall.id, ...(parentToolCallId ? { parentToolCallId } : {}), input: args as Record<string, unknown>, content: result.content, details: result.details, ...(result.structuredContent === undefined ? {} : { structuredContent: result.structuredContent }), isError, usage: result.usage, }) : undefined; const content = hookResult?.content ?? result.content ?? []; // Runs after the extension hook so images injected or replaced by extensions are normalized too. const resizeOptions = this._limitsModel()?.inputLimits?.images?.resize; const normalizedContent = await normalizeToolResultImages(content, { autoResizeImages: this.settingsManager.getImageAutoResize(), ...(resizeOptions ? { resizeOptions } : {}), }); if (!hookResult && normalizedContent === content) { return undefined; } // The hook result already dropped structured content that replaced content no longer matches. return { content: normalizedContent, details: hookResult?.details, structuredContent: hookResult ? hookResult.structuredContent : result.structuredContent, isError: hookResult?.isError ?? isError, usage: hookResult?.usage, };emitToolResult() nối chuỗi: mỗi handler thấy bản đã sửa bởi handler trước, và một handler thay content mà không trả structuredContent sẽ làm mất structuredContent, vì dữ liệu cấu trúc có thể không còn khớp với văn bản (runner.ts:1190). Output của npm test có một khoá dạng AKIA..., nên bash-guard trả content đã che. call_2 thì không bao giờ tới đây: kết quả immediate (bị chặn, không tìm thấy tool, sai schema) không đi qua afterToolCall, nên không có sự kiện tool_result nào cho nó.
Bước 4: ghi vào session
Khi cả lô xong, vòng lặp tạo hai message toolResult theo thứ tự call_1, call_2 và phát message_start/message_end cho từng cái (dòng 660 đến 663). AgentSession phát message_end cho extension trước rồi mới ghi entry (agent-session.ts:1142). Sau đó finishTurn phát boundary turn_end, và lượt kế tiếp gửi cho mô hình cả hai kết quả. Ví dụ minh hoạ: ba entry message liên quan trong tệp session (đã lược các trường không liên quan):
Ví dụ minh hoạ: ba dòng JSONL sau lượt này (rút gọn)
{"type":"message","id":"a1","parentId":"u0","message":{"role":"assistant","stopReason":"toolUse",
"content":[{"type":"toolCall","id":"call_1","name":"bash","arguments":{"command":"npm test"}},
{"type":"toolCall","id":"call_2","name":"bash","arguments":{"command":"rm -rf dist"}}]}}
{"type":"message","id":"r1","parentId":"a1","message":{"role":"toolResult","toolCallId":"call_1",
"toolName":"bash","isError":false,"content":[{"type":"text","text":"... key=[đã che] ... 12 passed"}]}}
{"type":"message","id":"r2","parentId":"r1","message":{"role":"toolResult","toolCallId":"call_2",
"toolName":"bash","isError":true,"details":{},"content":[{"type":"text","text":"bash-guard: rm -rf bị chặn"}]}}call_1 trong message assistant vẫn ghi {"command":"npm test"}: việc sửa tham số chỉ tác động lên bản sao đem đi thực thi. Định dạng đầy đủ của entry ở chương Session.Dòng thời gian của các sự kiện extension trong kịch bản:
- tool_execution_start(call_1)
argslà tham số gốc, không cótimeout. - tool_call(call_1)bash-guard gán
timeout = 120tại chỗ. - tool_execution_start(call_2)Vẫn trong vòng chuẩn bị tuần tự.
- tool_call(call_2)Trả
block; các handlertool_callsau không chạy. - tool_execution_end(call_2)
isError: true, phát ngay. - tool_execution_update(call_1)*
npm testđang chạy. - tool_result(call_1)bash-guard che khoá trong output.
- tool_execution_end(call_1)Kèm
durationMs. - message_end(toolResult call_1), message_end(toolResult call_2)Thứ tự gốc; mỗi cái thành một entry.
- turn_endBoundary của lượt.
Chú ý thứ tự tool_execution_end (call_2 trước call_1) khác thứ tự message kết quả (call_1 trước call_2). Một extension đếm hay vẽ theo tool_execution_* phải ghép theo toolCallId, không theo thứ tự đến; tài liệu cũng dặn không giả định lời gọi anh em đã tồn tại khi một sự kiện tool chạy (docs/extensions.md:236).
Ba ví dụ thật: plan mode, subagent, todo#
Thư mục examples/extensions/ có hơn bảy mươi ví dụ, được tài liệu gọi là "the implementation reference" (docs/extensions.md:386; danh mục ở packages/coding-agent/examples/extensions/README.md). Ba ví dụ dưới đây đáng đọc nhất vì chúng dựng lại đúng ba tính năng mà bài viết gốc nói Pi không có, và mỗi cái tập trung vào một phần khác của API.
plan-mode/: chế độ chỉ đọc ghép từ sáu sự kiện
Tính năng: /plan (hoặc Ctrl+Alt+P, hoặc cờ --plan) bật chế độ chỉ đọc; mô hình viết kế hoạch đánh số dưới tiêu đề "Plan:"; người dùng chọn thực thi; trong lúc thực thi, mô hình đánh dấu bước xong bằng [DONE:n] và một widget hiện tiến độ (packages/coding-agent/examples/extensions/plan-mode/README.md). Lõi Pi không chứa logic plan mode nào; mỗi mảnh là một điểm nối của API:
function enablePlanModeTools(): void { if (toolsBeforePlanMode === undefined) { toolsBeforePlanMode = pi.getActiveTools(); } pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));}function restoreNormalModeTools(): void { pi.setActiveTools(toolsBeforePlanMode ?? getNormalModeTools(pi.getActiveTools())); toolsBeforePlanMode = undefined;}function persistState(): void { pi.appendEntry("plan-mode", { enabled: planModeEnabled, todos: todoItems, executing: executionMode, toolsBeforePlanMode, });}// Block destructive bash commands in plan modepi.on("tool_call", async (event) => { if (!planModeEnabled || event.toolName !== "bash") return; const command = event.input.command as string; if (!isSafeCommand(command)) { return { block: true, reason: `Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: ${command}`, }; }});isSafeCommand() đòi lệnh vừa khớp một mẫu an toàn (bắt đầu bằng cat, grep, git status...) vừa không khớp mẫu phá huỷ nào, kể cả chuyển hướng > (utils.ts:97). Đây là danh sách cho phép dựa trên biểu thức chính quy, không phải hộp cát: find . -name "*.tmp" -delete khớp mẫu an toàn find và không chứa từ khoá phá huỷ nào trong danh sách, nên vẫn lọt qua. pi.on("before_agent_start", async (event) => { if (event.messageType !== "user") return; if (planModeEnabled) { return { message: { customType: "plan-mode-context", content: `[PLAN MODE ACTIVE]You are in plan mode - a read-only exploration mode for safe code analysis.Do NOT attempt to make changes - just describe what you would do.`, display: false, }, }; }display: false: message vào ngữ cảnh mô hình nhưng không hiện trong transcript. Lần chạy do custom message khởi động (messageType: "custom") được bỏ qua để chỉ dẫn không bị lặp.Ba mảnh còn lại: handler context lọc bỏ các message [PLAN MODE ACTIVE] cũ khi đã thoát plan mode (index.ts:176); agent_end trích các bước từ câu trả lời, hỏi bằng ctx.ui.select(), rồi bắt đầu thực thi bằng pi.sendMessage(..., { triggerTurn: true, deliverAs: "followUp" }) (index.ts:266); turn_end đếm [DONE:n] sau mỗi lượt và cất lại trạng thái (index.ts:254). Khi phiên mở lại, session_start tìm entry plan-mode cuối cùng và quét các message assistant sau mốc plan-mode-execute để dựng lại tiến độ (index.ts:343).
So với lời phê bình của Mario về plan mode của Claude Code ("I need observability for planning"), cách làm này để kế hoạch là văn bản assistant bình thường trong transcript; extension chỉ hạn chế tool và theo dõi tiến độ, không giấu bước nào.
subagent/: sub-agent là một tiến trình pi khác
Bài viết gốc gợi ý: "If you need pi to spawn itself, just ask it to run itself via bash." Ví dụ subagent/ biến lời gợi ý đó thành một tool có cấu trúc. Agent được định nghĩa bằng tệp Markdown có frontmatter name, description, tools, model, phần thân là system prompt bổ sung, đặt trong ~/.pi/agent/agents/ hoặc .pi/agents/ (agents.ts:62). Tool subagent có ba chế độ: một tác vụ, song song (tối đa 8, chạy 4 cùng lúc), và chuỗi với chỗ giữ {previous}. Phần lõi là dòng lệnh:
const args: string[] = ["--mode", "json", "-p", "--no-session"];const inheritsDispatchConfig = !agent.model;const model = agent.model ?? dispatchDefaults.model;if (model) args.push("--model", model);if (inheritsDispatchConfig && dispatchDefaults.thinkingLevel) { args.push("--thinking", dispatchDefaults.thinkingLevel);}if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(",")); args.push("--append-system-prompt", tmpPromptPath); } args.push(`Task: ${task}`); let wasAborted = false; const exitCode = await new Promise<number>((resolve) => { const invocation = getPiInvocation(args); const proc = spawn(invocation.command, invocation.args, { cwd: cwd ?? defaultCwd, shell: false, stdio: ["ignore", "pipe", "pipe"], });model trong định nghĩa thì agent con kế thừa mô hình và mức suy nghĩ của phiên cha (lấy từ ctx.model, ctx.thinkingLevel). getPiInvocation() tìm lại đúng binary hoặc script đang chạy để tiến trình con cùng phiên bản (index.ts:247).Tiến trình con chạy ở JSON mode (chương các chế độ), nên stdout là một dòng JSON cho mỗi sự kiện. Extension đọc từng dòng, giữ các message_end, cộng dồn usage, và gọi onUpdate để TUI của phiên cha vẽ tiến trình của con ngay khi nó diễn ra (index.ts:351). Đây là câu trả lời cho lời phê bình "zero visibility": mọi lời gọi tool của agent con hiện trong kết quả tool của cha. Ngữ cảnh thì cô lập thật sự, vì đó là một tiến trình khác với --no-session. Huỷ ở phiên cha gửi SIGTERM cho tiến trình con. Về an toàn, agent của dự án (.pi/agents) chỉ được dùng khi tham số agentScope là "project" hoặc "both", và nếu dự án chưa được tin cậy thì còn phải qua một ctx.ui.confirm() (index.ts:518).
todo.ts: trạng thái đi theo nhánh
Ví dụ thứ ba nhỏ nhưng chứa bài học quan trọng nhất về trạng thái. Tool todo giữ danh sách trong closure của factory, và mỗi kết quả trả về toàn bộ danh sách trong details:
case "add": { if (!params.text) { return { content: [{ type: "text", text: "Error: text required for add" }], details: { action: "add", todos: [...todos], nextId, error: "text required" } as TodoDetails, }; } const newTodo: Todo = { id: nextId++, text: params.text, done: false }; todos.push(newTodo); return { content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }], details: { action: "add", todos: [...todos], nextId } as TodoDetails, };}export default function (pi: ExtensionAPI) { // In-memory state (reconstructed from session on load) let todos: Todo[] = []; let nextId = 1; /** * Reconstruct state from session entries. * Scans tool results for this tool and applies them in order. */ const reconstructState = (ctx: ExtensionContext) => { todos = []; nextId = 1; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type !== "message") continue; const msg = entry.message; if (msg.role !== "toolResult" || msg.toolName !== "todo") continue; const details = msg.details as TodoDetails | undefined; if (details) { todos = details.todos; nextId = details.nextId; } } }; // Reconstruct state on session events pi.on("session_start", async (_event, ctx) => reconstructState(ctx)); pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));session_tree phát khi người dùng nhảy sang một điểm khác của cây bằng /tree.Ví dụ minh hoạ: mô hình gọi todo add "viết test" (kết quả r1, details.todos = [#1]) rồi todo add "sửa bug" (kết quả r2, [#1, #2]). Người dùng dùng /tree quay về ngay sau r1 để thử hướng khác. Pi phát session_tree; reconstructState() duyệt getBranch(), giờ là đường từ gốc tới r1, và danh sách trong bộ nhớ trở lại [#1]. Nếu trạng thái được lưu trong một tệp ngoài, nó vẫn sẽ là [#1, #2], sai với lịch sử mà mô hình đang thấy. Cũng vì vậy /todos chỉ cần đọc biến trong bộ nhớ rồi vẽ bằng ctx.ui.custom(), sau khi kiểm tra ctx.mode === "tui" (todo.ts:284). Ba ví dụ chọn ba cách lưu khác nhau: todo dùng details (đi theo nhánh), plan-mode dùng appendEntry (không vào ngữ cảnh), subagent không lưu gì của tiến trình con ngoài chính kết quả tool (kể cả details).
Skills#
Vấn đề: repo Pi có hướng dẫn riêng cho việc phát hành, cho việc thêm một provider LLM, cho việc kiểm thử interactive mode trong tmux. Mỗi bộ hướng dẫn chỉ cần cho một loại việc. Nhét tất cả vào system prompt thì mọi yêu cầu đều trả tiền cho chúng, kể cả khi người dùng chỉ hỏi một câu về CSS. Skill giải bằng cách công bố dần (progressive disclosure): system prompt chỉ chứa tên, mô tả và đường dẫn của mỗi skill; khi nhiệm vụ khớp mô tả, mô hình tự đọc tệp bằng tool read (docs/skills.md:41). Pi hiện thực đặc tả Agent Skills, nên skill viết cho các agent khác dùng được ở đây. Chính repo Pi có bốn skill trong .pi/skills/, và AGENTS.md của repo bảo agent "load and follow" .pi/skills/release.md khi làm việc phát hành (AGENTS.md:121).
export interface SkillFrontmatter { name?: string; description?: string; "disable-model-invocation"?: boolean; [key: string]: unknown;}export interface Skill { name: string; description: string; filePath: string; baseDir: string; sourceInfo: SourceInfo; disableModelInvocation: boolean;}baseDir là thư mục chứa tệp skill; đường dẫn tương đối trong skill được giải theo nó. Các trường khác của đặc tả (license, compatibility, metadata, allowed-tools) được đọc vào frontmatter nhưng Pi không dùng.Khám phá
Skill đến từ ~/.pi/agent/skills/, ~/.agents/skills/, .pi/skills/ và .agents/skills/ của dự án (cần trust), mục skills trong settings, gói, --skill, và các đường dẫn extension trả về trong resources_discover. Với .agents/skills của dự án, Pi xét thư mục hiện tại và các thư mục cha, dừng ở gốc repo git (package-manager.ts:474). Quy tắc quét trong một thư mục: thư mục nào có SKILL.md thì đó là gốc của một skill và không quét sâu hơn; nếu không thì đệ quy vào thư mục con, bỏ qua tên bắt đầu bằng dấu chấm, node_modules và những gì .gitignore/.ignore/.fdignore loại. Tệp .md lẻ được nhận làm skill khi nằm ngay ở gốc thư mục skill kiểu Pi, hoặc ở thư mục con không có SKILL.md với thư mục kiểu .agents (package-manager.ts:377). Bốn tệp .pi/skills/*.md của repo Pi thuộc trường hợp đầu.
Kiểm tra hợp lệ
Pi theo đặc tả nhưng khoan dung: phần lớn vi phạm chỉ tạo cảnh báo. Luật cho tên và mô tả:
function validateName(name: string): string[] { const errors: string[] = []; if (name.length > MAX_NAME_LENGTH) { errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`); } if (!/^[a-z0-9-]+$/.test(name)) { errors.push(`name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)`); } if (name.startsWith("-") || name.endsWith("-")) { errors.push(`name must not start or end with a hyphen`); } if (name.includes("--")) { errors.push(`name must not contain consecutive hyphens`); } return errors;}/** * Validate description per Agent Skills spec. */function validateDescription(description: unknown): string[] { const errors: string[] = []; if (typeof description !== "string" || description.trim() === "") { errors.push("description is required"); } else if (description.length > MAX_DESCRIPTION_LENGTH) { errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`); } return errors;}Và luật quyết định skill có được nạp hay không:
let frontmatter: SkillFrontmatter; try { ({ frontmatter } = parseFrontmatter<SkillFrontmatter>(rawContent)); } catch (error) { if (isDeclaredSkill) { const message = error instanceof Error ? error.message : "failed to parse skill file"; diagnostics.push({ type: "warning", message, path: filePath }); } return { skill: null, diagnostics }; } const description = frontmatter.description; const hasDescription = typeof description === "string" && description.trim() !== ""; if (!isDeclaredSkill && !hasDescription) { return { skill: null, diagnostics }; } const skillDir = dirname(filePath); const parentDirName = basename(skillDir); // Validate description const descErrors = validateDescription(description); for (const error of descErrors) { diagnostics.push({ type: "warning", message: error, path: filePath }); } // Use name from frontmatter, or fall back to parent directory name const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined; const name = frontmatterName || parentDirName; // Validate name const nameErrors = validateName(name); for (const error of nameErrors) { diagnostics.push({ type: "warning", message: error, path: filePath }); } // Still load the skill even with warnings, unless description is missing or empty. if (!hasDescription) { return { skill: null, diagnostics }; } return { skill: { name, description, filePath, baseDir: skillDir, sourceInfo: createSkillSourceInfo(filePath, skillDir, source), disableModelInvocation: frontmatter["disable-model-invocation"] === true, }, diagnostics, };}isDeclaredSkill (dòng 282) phân biệt SKILL.md với tệp .md lẻ: một tệp lẻ không có mô tả bị bỏ qua lặng lẽ, vì có thể nó chỉ là tài liệu thường nằm cạnh skill.Thư mục test/fixtures/skills/ ghi lại từng trường hợp biên, và packages/coding-agent/test/skills.test.ts khẳng định kết quả của chúng:
| Fixture | Đặc điểm | Kết quả |
|---|---|---|
valid-skill | Đúng chuẩn | Nạp, không cảnh báo |
name-mismatch | name: different-name khác tên thư mục | Nạp với tên trong frontmatter, không cảnh báo |
invalid-name-chars | Invalid_Name | Nạp, cảnh báo ký tự không hợp lệ |
long-name, consecutive-hyphens | Tên quá 64 ký tự; bad--name | Nạp, có cảnh báo |
missing-description, no-frontmatter | Không có mô tả | Bỏ, cảnh báo "description is required" |
invalid-yaml | description: [unclosed bracket | Bỏ, cảnh báo lỗi phân tích YAML |
multiline-description | Mô tả | nhiều dòng | Nạp, giữ nguyên các dòng |
unknown-field | author, version | Nạp, trường lạ bị bỏ qua |
root-skill-preferred | Có SKILL.md ở gốc và một SKILL.md lồng bên trong | Chỉ skill gốc được nạp, tên lấy từ thư mục |
nested/child-skill | Thư mục cha không có SKILL.md | Tìm thấy nhờ đệ quy |
disable-model-invocation | disable-model-invocation: true | Nạp, nhưng không có trong system prompt |
skills-collision/{first,second}/calendar | Hai skill cùng tên calendar | Giữ cái gặp trước, báo collision |
Trùng tên được xử lý trong loadSkills(): một tệp đã nạp qua symlink thì bị bỏ lặng lẽ (so theo đường dẫn thật), còn hai tệp khác nhau trùng tên thì cái đến trước thắng (skills.ts:426). "Đến trước" ở đây là theo hạng ưu tiên của resolver, nên skill của dự án thắng skill cùng tên của người dùng.
Vào system prompt, và đọc khi cần
export function formatSkillsForPrompt(skills: Skill[], fileReadTool: "read" | "bash" | "indirect" = "read"): string { const visibleSkills = skills.filter((s) => !s.disableModelInvocation); if (visibleSkills.length === 0) { return ""; } const lines = [ "\n\nThe following skills provide specialized instructions for specific tasks.", fileReadTool === "read" ? "Use the read tool to load a skill's file when the task matches its description." : fileReadTool === "bash" ? "Use bash to load a skill's file when the task matches its description." : "Load a skill's file when the task matches its description.", "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", "", "<available_skills>", ]; for (const skill of visibleSkills) { lines.push(" <skill>"); lines.push(` <name>${escapeXml(skill.name)}</name>`); lines.push(` <description>${escapeXml(skill.description)}</description>`); lines.push(` <location>${escapeXml(skill.filePath)}</location>`); lines.push(" </skill>"); } lines.push("</available_skills>"); return lines.join("\n");}Câu hướng dẫn nêu tên tool đọc tệp, nên phải chọn đúng tool đang có:
// A hidden reader is still reachable through another tool, so skills stay but the hint names no tool.const readers = ["read", "bash"] as const;const skillFileReadTool = readers.find((tool) => declaredTools.includes(tool)) ?? (readers.some((tool) => selectedTools.includes(tool)) ? "indirect" : undefined);if (skillFileReadTool && skills.length > 0) { const skillsPrompt = formatSkillsForPrompt(skills, skillFileReadTool).trim(); if (skillsPrompt) promptSections.skills = skillsPrompt;}read lẫn bash đều không bật, mục skill bị bỏ hẳn khỏi system prompt: không có cách nào đọc tệp thì liệt kê skill là vô ích. indirect là trường hợp tool đọc đang bật nhưng bị ẩn khai báo (chẳng hạn chỉ gọi được qua codemode).Mô hình có thể bỏ lỡ một skill liên quan, nên người dùng có thể ép bằng /skill:tên tham số. AgentSession thay lệnh đó bằng phần thân của tệp (đã bỏ frontmatter) bọc trong thẻ <skill name location> kèm câu "References are relative to ...", tham số được nối phía sau như một yêu cầu (agent-session.ts:2211). Lệnh này chạy được cả với skill disable-model-invocation, vì cờ đó chỉ ẩn skill khỏi danh sách cho mô hình; setting enableSkillCommands chỉ quyết định skill có xuất hiện trong gợi ý lệnh hay không (docs/skills.md:53).
Prompt templates#
Prompt template là loại tài nguyên đơn giản nhất: một tệp Markdown trở thành một lệnh /tên, không có mã chạy, không có tệp phụ trợ (docs/prompt-templates.md:3). Trước bản 0.35.0 chúng được gọi là "slash commands"; changelog của bản đó đổi tên chúng cùng lúc với việc gộp hooks và custom tools thành extension (CHANGELOG.md:4769).
export interface PromptTemplate { name: string; description: string; argumentHint?: string; content: string; sourceInfo: SourceInfo; filePath: string; // Absolute path to the template file}const name = basename(filePath).replace(/\.md$/, "");// Get description from frontmatter or first non-empty linelet description = typeof frontmatter.description === "string" ? frontmatter.description : "";if (!description) { const firstLine = body.split("\n").find((line) => line.trim()); if (firstLine) { // Truncate if too long description = firstLine.slice(0, 60); if (firstLine.length > 60) description += "..."; }}const argumentHint = typeof frontmatter["argument-hint"] === "string" ? frontmatter["argument-hint"] : undefined;return { template: { name, description, ...(argumentHint && { argumentHint }), content: body, sourceInfo, filePath, }, diagnostics,};Khám phá theo đúng mô hình của các tài nguyên khác (~/.pi/agent/prompts/, .pi/prompts/ khi tin cậy, settings, gói, --prompt-template, resources_discover), với một khác biệt: thư mục quy ước chỉ nạp các tệp .md nằm trực tiếp trong nó, không đệ quy (prompt-templates.ts:161); muốn lấy tệp lồng sâu thì liệt kê chúng trong settings hay manifest của gói. Hai template trùng tên thì cái đến trước thắng và có một chẩn đoán collision (resource-loader.ts:1160).
Repo Pi tự dùng template cho các việc lặp lại của người bảo trì. Ví dụ /is:
---description: Analyze GitHub issues (bugs or feature requests)argument-hint: "<issue>"---Analyze GitHub issue(s): $ARGUMENTSKhi người dùng gõ /is 1234 5678, expandPromptTemplate() tách tên khỏi phần còn lại, tìm template tên is, tách tham số kiểu shell, rồi thay chỗ giữ (prompt-templates.ts:309). Kết quả, dòng 5 trở thành Analyze GitHub issue(s): 1234 5678. Bảng cú pháp đầy đủ nằm trong chính hàm thay thế:
/** * Substitute argument placeholders in template content * Supports: * - $1, $2, ... for positional args * - $@ and $ARGUMENTS for all args * - ${N:-default} for positional arg N with default when missing/empty * - ${@:-default} and ${ARGUMENTS:-default} for all args with a default when empty * - ${@:N} for args from Nth onwards (bash-style slicing) * - ${@:N:L} for L args starting from Nth * * Note: Replacement happens on the template string only. Argument and default values * containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted. */export function substituteArgs(content: string, args: string[]): string { const allArgs = args.join(" "); return content.replace( /\$\{(\d+|ARGUMENTS|@):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g, (_match, defaultTarget, defaultValue, sliceStart, sliceLength, simple) => { if (defaultTarget) { const value = defaultTarget === "@" || defaultTarget === "ARGUMENTS" ? allArgs : args[parseInt(defaultTarget, 10) - 1]; return value ? value : defaultValue; } if (sliceStart) { let start = parseInt(sliceStart, 10) - 1; // Convert to 0-indexed (user provides 1-indexed) // Treat 0 as 1 (bash convention: args start at 1) if (start < 0) start = 0; if (sliceLength) { const length = parseInt(sliceLength, 10); return args.slice(start, start + length).join(" "); } return args.slice(start).join(" "); } if (simple === "ARGUMENTS" || simple === "@") { return allArgs; } const index = parseInt(simple, 10) - 1; return args[index] ?? ""; }, );}$1 không bị thay tiếp. $3 khi chỉ có hai tham số thành chuỗi rỗng. Tách tham số tôn trọng dấu nháy, nên /review "API compatibility" là một tham số, và "" là một tham số rỗng có thật (parseCommandArgs()).Vị trí của bước mở rộng trong luồng xử lý prompt quan trọng: lệnh extension trùng tên chạy trước và chiếm luôn, sự kiện input thấy văn bản thô /is 1234 5678 trước khi template được mở rộng, và extension gửi tin bằng pi.sendUserMessage() chỉ được mở rộng khi truyền expandPromptTemplates: true (chương AgentSession). Template cũng là cách gọn để đóng gói một quy trình nhiều bước dùng extension khác: thư mục subagent/prompts/ có /implement, chỉ là một đoạn văn bảo mô hình gọi tool subagent theo chuỗi scout, planner, worker với $@ làm đề bài (packages/coding-agent/examples/extensions/subagent/prompts/implement.md).
Themes#
Theme quyết định màu của interactive mode và của bản xuất HTML. Pi có ba theme dựng sẵn: system (mặc định, dựng màu từ chính bảng màu của terminal), dark và light; setting theme nhận một tên, hoặc cặp "light/dark" để đổi theo giao diện terminal (docs/themes.md:40). Thuật toán của theme system (giải độ sáng theo yêu cầu tương phản trong OKLCH) thuộc chương pi-tui. Ở đây ta chỉ quan tâm theme tự viết, một tệp JSON:
export const ThemeJsonSchema = Type.Object( { $schema: Type.Optional(Type.String({ description: "JSON schema reference" })), name: Type.String({ pattern: "^[^/]+$", description: "Theme name. Must not contain '/' because it is reserved for automatic light/dark theme settings.", }), appearance: Type.Optional( Type.Union([Type.Literal("dark"), Type.Literal("light")], { description: "Background the theme is designed for. Detected from the theme colors when omitted.", }), ), vars: Type.Optional( Type.Record(Type.String(), ColorValueSchema, { description: "Reusable color variables", }), ), colors: ThemeColorsSchema, export: Type.Optional( Type.Object( { pageBg: Type.Optional(colorValue("Page background color")), cardBg: Type.Optional(colorValue("Card/container background color")), infoBg: Type.Optional(colorValue("Info sections background (system prompt, notices)")), }, { description: "Optional colors for HTML export (defaults derived from userMessageBg if not specified)", additionalProperties: false, }, ), ), }, { title: "Pi Coding Agent Theme", description: "Theme schema for Pi coding agent", additionalProperties: false, },);theme.ts có chủ đích: theo chú thích đầu tệp, import TypeBox kéo theo khoảng 17 MB module, nên chỉ nơi cần kiểm tra theme do người dùng viết mới cài nó. main.ts của CLI cài nó làm validator trước khi nạp tài nguyên (main.ts:577); mã chỉ dùng theme dựng sẵn thì không phải trả chi phí đó.Màu viết được ở sáu dạng: hex, oklch(...), okhsl(...), chỉ số 256 màu, tên biến trong vars, hoặc chuỗi rỗng cho màu mặc định của terminal; biến có thể tham chiếu biến khác, và biến thiếu hay vòng tham chiếu làm theme không hợp lệ (docs/themes.md:83). Resource loader nạp theme từ ~/.pi/agent/themes/, .pi/themes/ (khi tin cậy), settings, gói, --theme, resources_discover. Tệp hỏng chỉ thành một chẩn đoán cảnh báo, không làm Pi dừng (resource-loader.ts:1121), và tên trùng thì cái đến trước thắng. Khi tra một tên, loadTheme() ưu tiên theo thứ tự: system luôn là theme dựng sẵn (một theme tự viết đặt tên system bị bỏ qua), rồi theme đã đăng ký từ resource loader, rồi theme dựng sẵn, rồi tệp <tên>.json trong thư mục theme của người dùng (theme.ts:557, theme.ts:487).
Nạp lại trực tiếp
Chỉnh màu là việc thử đi thử lại, nên theme đang dùng được theo dõi trên đĩa và áp dụng ngay khi lưu, không cần /reload:
function startThemeWatcher(): void { stopThemeWatcher(); // Only watch if it's a custom theme (not built-in) if ( !currentThemeName || currentThemeName === "dark" || currentThemeName === "light" || currentThemeName === SYSTEM_THEME_NAME ) { return; } const scheduleReload = () => { if (themeReloadTimer) { clearTimeout(themeReloadTimer); } themeReloadTimer = setTimeout(() => { themeReloadTimer = undefined; // Ignore stale timers after switching themes or stopping the watcher if (currentThemeName !== watchedThemeName) { return; } // Keep the last successfully loaded theme active if the file is temporarily missing if (!fs.existsSync(themeFile)) { return; } try { // Reload the theme from disk and refresh the registry cache const reloadedTheme = loadThemeFromPath(themeFile); registeredThemes.set(watchedThemeName, reloadedTheme); setGlobalTheme(reloadedTheme); // Notify callback (to invalidate UI) if (onThemeChangeCallback) { onThemeChangeCallback(); } } catch (_error) { // Ignore errors (file might be in invalid state while being edited) } }, 100); };~/.pi/agent/themes/ rồi lọc theo tên tệp; sự kiện không kèm tên tệp cũng kích hoạt nạp lại (theme.ts:789). Lỗi phân tích lúc tệp đang sửa dở bị bỏ qua, theme cũ vẫn hiển thị.Watcher chỉ chạy cho theme tự viết nằm đúng ở <agent-dir>/themes/<tên>.json và chỉ trong interactive mode (main.ts:906). Theme từ dự án, từ gói hay từ một đường dẫn trong settings thì phải /reload; tài liệu nói đúng như vậy (docs/themes.md:68). Extension đổi theme bằng ctx.ui.setTheme(tên hoặc Theme); ví dụ mac-system-theme.ts dùng nó để theo chế độ sáng tối của macOS.
Pi packages#
Một extension có thể cần thư viện riêng, đi cùng vài skill và một prompt template. Chép tay từng tệp vào ~/.pi/agent/ thì không cập nhật được, không chia sẻ được. Pi package là đơn vị phân phối: một thư mục bình thường hoặc một gói npm, cài bằng một lệnh (docs/packages.md:3). Chuỗi nguồn được phân loại như sau:
private parseSource(source: string): ParsedSource { if (source.startsWith("npm:")) { const spec = source.slice("npm:".length).trim(); const { name, version } = this.parseNpmSpec(spec); return { type: "npm", spec, name, version, range: getNpmVersionRange(version), pinned: isExactNpmVersion(version), }; } if (isLocalPath(source)) { return { type: "local", path: source }; } // Try parsing as git URL const gitParsed = parseGitUrl(source); if (gitParsed) { return gitParsed; } return { type: "local", path: source };}pinned của nguồn npm đúng khi phiên bản là một semver chính xác (npm:@x/y@1.0.0), không phải một khoảng như @^1.0.0. Nguồn git có tiền tố git: nhận các dạng viết tắt; không có tiền tố thì chỉ nhận URL có giao thức (git.ts:165).pi install <nguồn> gọi installAndPersist(): cài, rồi thêm nguồn vào mảng packages của ~/.pi/agent/settings.json, hoặc của .pi/settings.json với -l (cần dự án đã được tin cậy) (package-manager.ts:1042). Gói npm vào một dự án npm riêng của Pi ở ~/.pi/agent/npm (hoặc .pi/npm); repo git được clone vào ~/.pi/agent/git/<host>/<path> rồi chạy npm install nếu có package.json; đường dẫn cục bộ thì không sao chép gì (package-manager.ts:2091). Lúc cài, việc giải peer dependency bị tắt (--legacy-peer-deps với npm, cấu hình tương đương với pnpm và Bun), để trình quản lý gói không tự kéo về một bản @earendil-works/pi-* thứ hai (package-manager.ts:1851). pi -e npm:@x/y thử một gói cho một lần chạy mà không ghi vào settings, cài vào thư mục tạm.
Manifest và các loại tài nguyên
export interface PiManifest { extensions?: string[]; skills?: string[]; prompts?: string[]; themes?: string[];}private collectPackageResources( packageRoot: string, accumulator: ResourceAccumulator, filter: PackageFilter | undefined, metadata: PathMetadata,): boolean { if (filter) { for (const resourceType of RESOURCE_TYPES) { const patterns = filter[resourceType]; const target = this.getTargetMap(accumulator, resourceType); if (filter.autoload === false) { this.applyPackageDeltaFilter(packageRoot, patterns ?? [], resourceType, target, metadata); } else if (patterns !== undefined) { this.applyPackageFilter(packageRoot, patterns, resourceType, target, metadata); } else { this.collectDefaultResources(packageRoot, resourceType, target, metadata); } } return true; } const manifest = readPiManifest(join(packageRoot, "package.json")); if (manifest) { for (const resourceType of RESOURCE_TYPES) { const entries = manifest[resourceType as keyof PiManifest]; this.addManifestEntries( entries, packageRoot, resourceType, this.getTargetMap(accumulator, resourceType), metadata, ); } return true; } let hasAnyDir = false; for (const resourceType of RESOURCE_TYPES) { const dir = join(packageRoot, resourceType); if (existsSync(dir)) { // Collect all files from the directory (all enabled by default) const files = collectResourceFiles(dir, resourceType); for (const f of files) { this.addResource(this.getTargetMap(accumulator, resourceType), f, metadata, true); } hasAnyDir = true; } } return hasAnyDir;}extensions/, skills/, prompts/, themes/.Mảng trong manifest nhận glob và mẫu loại trừ. Ví dụ minh hoạ một package.json của gói:
Ví dụ minh hoạ: package.json của một Pi package
{
"name": "@acme/pi-review",
"keywords": ["pi-package"], ← để hiện trong thư viện gói của pi.dev
"pi": {
"extensions": ["./src/index.ts"],
"skills": ["./skills"],
"prompts": ["./prompts/*.md", "!prompts/draft-*.md"]
},
"dependencies": { "diff": "8.0.2" },
"peerDependencies": { "@earendil-works/pi-coding-agent": "*", "typebox": "*" }
}peerDependencies với khoảng "*", không trong dependencies (docs/packages.md:76).Người dùng thu hẹp một gói bằng dạng object trong settings: bỏ một trường thì nạp hết những gì gói cho phép, [] thì không nạp loại đó, !mẫu loại trừ theo glob, +đường_dẫn và -đường_dẫn ép bật hay ép tắt đúng một tệp. Bộ lọc chỉ thu hẹp được những gì manifest đã công bố, không mở ra thứ gói không khai báo (docs/packages.md:94). Bốn bước áp mẫu (include, loại trừ, ép bật, ép tắt) nằm trong applyPatterns() (package-manager.ts:743), và cùng cú pháp đó dùng cho các mảng extensions, skills, prompts, themes ở cấp settings.
Ghim phiên bản và cập nhật
for (const entry of sources) { const parsed = this.parseSource(entry.source); // Pinned npm versions are fixed. Pinned git refs are configured checkout targets, // so include them to reconcile an existing clone when the configured ref changes. if (parsed.type === "npm") { if (!parsed.pinned) { npmCandidates.push({ ...entry, parsed }); } } else if (parsed.type === "git") { gitCandidates.push({ ...entry, parsed }); }}Với npm, nguồn chưa ghim được so phiên bản đã cài với phiên bản mới nhất thoả khoảng; nguồn không có phiên bản thì cài @latest (package-manager.ts:1187). Với git, nguồn có ref (@v1, một commit, hay một tên nhánh) được fetch đúng ref đó rồi reset --hard về nó; nguồn không có ref thì đi theo upstream của clone (package-manager.ts:1931). Vì thế một tag hay commit không bao giờ dịch chuyển, nhưng một ref là tên nhánh thì vẫn tiến theo nhánh đó. Cập nhật chạy bằng pi update --extensions (mọi gói) hoặc pi update <nguồn> (một gói); pi update không tham số chỉ cập nhật chính Pi (package-manager-cli.ts:359). Biến môi trường PI_OFFLINE=1 tắt cả việc cập nhật lẫn việc tự cài gói còn thiếu lúc khởi động.
Một gói có thể xuất hiện ở cả settings người dùng và settings dự án. Danh tính để so trùng: gói npm theo tên, gói git theo URL repo không kèm ref, gói cục bộ theo đường dẫn tuyệt đối. Mục của dự án thay mục của người dùng; riêng mục dự án có autoload: false thì trở thành một bộ lọc "delta" đè lên mục của người dùng (package-manager.ts:1351).
pi config: bật tắt từng tài nguyên
pi config mở một TUI liệt kê mọi tài nguyên đã giải, kể cả các tài nguyên đang tắt và bốn built-in. Bật hay tắt một mục không xoá tệp nào; nó chỉ ghi một mẫu +đường_dẫn hoặc -đường_dẫn vào mảng tương ứng trong settings, hoặc vào bộ lọc của gói nếu tài nguyên thuộc một gói (config-selector.ts:538). Mặc định nó sửa settings người dùng; Tab hoặc pi config -l chuyển sang chế độ ghi đè cho dự án, chỉ khả dụng khi dự án được tin cậy (package-manager-cli.ts:814). Ba lệnh còn lại là pi list (liệt kê gói theo scope), pi remove (alias pi uninstall) và pi update.
Keybindings và provider tự viết#
Keybindings: hành động có tên, phím đổi được
Pi tránh gắn cứng phím vào code. Mỗi hành động có một id ổn định (app.model.select, tui.editor.undo...), một mô tả và phím mặc định; quy tắc của repo cấm viết thẳng kiểu matchesKey(data, "ctrl+x") mà bắt thêm phím mặc định vào bảng để người dùng đổi được (AGENTS.md:27). Bảng của coding agent là bảng của pi-tui cộng các hành động ứng dụng, với phím mặc định khác đi trên Windows và WSL (keybindings.ts:16). Người dùng ghi đè trong <agent-dir>/keybindings.json: mỗi id ánh xạ tới một phím hoặc một mảng phím, mảng rỗng thì tắt hành động (docs/keybindings.md:7).
export class KeybindingsManager extends TuiKeybindingsManager { private configPath: string | undefined; constructor(userBindings: KeybindingsConfig = {}, configPath?: string) { super(KEYBINDINGS, userBindings); this.configPath = configPath; } static create(agentDir: string = getAgentDir()): KeybindingsManager { const configPath = join(agentDir, "keybindings.json"); const userBindings = KeybindingsManager.loadFromFile(configPath); return new KeybindingsManager(userBindings, configPath); } reload(): void { if (!this.configPath) return; this.setUserBindings(KeybindingsManager.loadFromFile(this.configPath)); } getEffectiveConfig(): KeybindingsConfig { return this.getResolvedBindings(); } private static loadFromFile(path: string): KeybindingsConfig { const rawConfig = loadRawConfig(path); if (!rawConfig) return {}; return toKeybindingsConfig(migrateKeybindingsConfig(rawConfig).config); }}toKeybindingsConfig() giữ lại chỉ những giá trị qua được schema TypeBox, nên một mục viết sai bị bỏ lặng lẽ thay vì làm hỏng cả tệp (keybindings.ts:269). Tên kiểu cũ như cursorUp được đổi sang tui.editor.cursorUp. /reload gọi reload() để áp dụng tệp vừa sửa.Phím tắt của extension (pi.registerShortcut("ctrl+shift+p", ...)) phải chung sống với bảng đó. Runner phân xử dựa trên các phím hiệu lực, tức sau khi đã áp keybindings.json:
// Extension shortcuts compete with canonical keybinding ids from keybindings.json.// Only editor-global shortcuts are reserved here. Picker-specific bindings are not.const RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS = [ "app.interrupt", "app.clear", "app.exit", "app.suspend", "app.thinking.cycle", "app.model.cycleForward", "app.model.cycleBackward", "app.model.select", "app.tools.expand", "app.thinking.toggle", "app.editor.external", "app.message.copy", "app.message.followUp", "tui.input.submit", "tui.select.confirm", "tui.select.cancel", "tui.input.copy", "tui.editor.deleteToLineEnd",] as const; for (const ext of this.extensions) { for (const [key, shortcut] of ext.shortcuts) { const normalizedKey = key.toLowerCase() as KeyId; const builtInKeybinding = builtinKeybindings[normalizedKey]; if (builtInKeybinding?.restrictOverride === true) { addDiagnostic( `Extension shortcut '${key}' from ${shortcut.extensionPath} conflicts with built-in shortcut. Skipping.`, shortcut.extensionPath, ); continue; } if (builtInKeybinding?.restrictOverride === false) { addDiagnostic( `Extension shortcut conflict: '${key}' is built-in shortcut for ${builtInKeybinding.keybinding} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`, shortcut.extensionPath, ); } const existingExtensionShortcut = extensionShortcuts.get(normalizedKey); if (existingExtensionShortcut) { addDiagnostic( `Extension shortcut conflict: '${key}' registered by both ${existingExtensionShortcut.extensionPath} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`, shortcut.extensionPath, ); } extensionShortcuts.set(normalizedKey, shortcut); } } return extensionShortcuts;app.model.select khỏi Ctrl+L trong keybindings.json, phím đó không còn bị giữ cho hành động ấy.Provider tự viết
Một dịch vụ mô hình đã nói đúng một API có sẵn thì chỉ cần khai báo trong models.json. Extension provider dành cho phần còn lại: đăng nhập riêng, danh mục mô hình động, giao thức không có sẵn (docs/custom-provider.md:7). Có hai dạng đăng ký: một Provider hoàn chỉnh của pi-ai (dạng được khuyên cho tích hợp mới), hoặc tên cộng ProviderConfig, dạng cũ:
/** Configuration for registering a provider via pi.registerProvider(). */export interface ProviderConfig { /** Display name for the provider in UI. */ name?: string; /** Base URL for the API endpoint. Required when defining models. */ baseUrl?: string; /** API key literal, env interpolation ($ENV_VAR or ${ENV_VAR}), or leading !command. Required when defining models (unless oauth provided). */ apiKey?: string; /** API type. Required at provider or model level when defining models. */ api?: Api; /** * Optional streamSimple handler for custom APIs. * The context is a normalized transcript: read the prompt and tools from its system messages * (`getCurrentSystemPrompt(context.messages)`, `getCurrentTools(context.messages)`). * Implementations must invoke `options.onPayload` before sending the provider request and use any * returned replacement payload. They must invoke `options.onResponse` after receiving the response * and before consuming its body, matching built-in providers. Implementations may invoke * `options.onProviderStreamEvent(data, model)` with parsed stream events before normalization. * Event data is adapter-owned and must be treated as read-only. */ streamSimple?: ( model: Model<Api>, context: TranscriptContext, options?: SimpleStreamOptions, ) => AssistantMessageEventStream; /** Image-generation implementations keyed by image API. */ images?: Partial<Record<ImageApi, ProviderImages>>; /** Classifier implementations keyed by classifier API. */ classifiers?: Partial<Record<ClassifierApi, ProviderClassifier>>; /** Custom headers to include in requests. */ headers?: Record<string, string>; /** If true, adds Authorization: Bearer header with the resolved API key. */ authHeader?: boolean; /** Models to register. If provided, replaces all existing models for this provider. */ models?: ProviderModelConfig[]; /** * Refresh this provider's model list. The returned list replaces extension-provided models. * Use context.publish({ persist: entry }) when the catalog should persist across sessions. */ refreshModels?(context: RefreshModelsContext): Promise<ProviderModelConfig[]>; /** OAuth provider for /login support. The `id` is set automatically from the provider name. */ oauth?: { /** Display name for the provider in login UI. */apiKey dùng cùng cú pháp với models.json: $NAME hay ${NAME} đọc biến môi trường, !lệnh lấy output của lệnh (docs/custom-provider.md:126). Khối oauth (dòng 1971 đến 1986) gồm login, refreshToken, getApiKey; đăng ký xong, provider xuất hiện trong /login và credential được lưu vào auth.json.Ngữ nghĩa nằm trong chú thích của registerProvider: có models thì thay toàn bộ mô hình của provider đó; chỉ có baseUrl (hay headers) thì giữ danh mục dựng sẵn và chỉ đổi điểm cuối, cách đúng để đi qua một proxy hay gateway (types.ts:1806). Thời điểm cũng quan trọng. Trong lúc nạp, đăng ký được xếp hàng (applyRuntimeChange ở mục nạp), và createAgentSessionServices() áp dụng hàng đợi ngay sau khi resource loader nạp xong, trước khi chọn mô hình khởi đầu (agent-session-services.ts:154). Nhờ vậy pi --model my-proxy/x và pi --list-models thấy được provider do extension thêm, và factory bất đồng bộ được phép await một yêu cầu mạng để lấy danh mục trước khi đăng ký. Sau khi runner được bind, registerProvider và unregisterProvider có hiệu lực ngay, không cần /reload (runner.ts:523).
Một provider tự hiện thực streamSimple phải tôn trọng các móc đo đạc mà pi-ai truyền vào: gọi options.onPayload trước khi gửi và dùng payload thay thế nếu có, gọi options.onResponse trước khi đọc body, và onProviderStreamEvent cho từng sự kiện luồng. Thiếu chúng thì các sự kiện before_provider_request, after_provider_response, provider_stream_event của mọi extension khác sẽ im lặng với provider đó (docs/custom-provider.md:191). Hai ví dụ đầy đủ: custom-provider-gitlab-duo/ (OAuth riêng, nhưng giao phần stream cho hiện thực Anthropic và OpenAI có sẵn của pi-ai) và custom-provider-anthropic/ (tự viết cả stream). Giao thức stream và adapter thuộc chương pi-ai; virtual model, thứ cũng đăng ký qua extension, thuộc chương Pi 1.0.
Quyết định thiết kế#
Tính năng nằm ngoài lõi, kể cả tính năng của chính Pi
Plan mode, sub-agent, todo, hỏi quyền trước lệnh nguy hiểm: tất cả là ví dụ extension chứ không phải mã lõi. Cái lõi không có thì không làm phình system prompt và không áp một quy trình cho mọi người. Pi 1.0 đi xa hơn với chính các tính năng mới của nó: MCP, codemode và tool search là extension dựng sẵn, nạp qua cùng cơ chế tài nguyên, tắt được, và nhường chỗ được cho một hiện thực của bên thứ ba nhờ cờ replaceable. Bài "You Said No MCP!" của Earendil giải thích vì sao MCP vẫn vào bản phát hành (earendil.com/posts/you-said-no-mcp); cấu trúc builtin:<name> là cách đưa nó vào mà không biến nó thành phần không gỡ được. Bài "Pi, Minimal and Performant" của Earendil, viết quanh kết quả benchmark nội bộ của Databricks, tóm thái độ chung: độ phức tạp phải tự chứng minh giá trị của nó (earendil.com/posts/pi-autoresearch-and-databricks).
Chạy trong tiến trình, không hộp cát
Extension có quyền như Pi: đọc credential, sửa mọi lời gọi tool, đăng ký provider, vẽ UI. Một API có hộp cát (tiến trình riêng, quyền khai báo trước) sẽ an toàn hơn nhưng không làm được nhiều việc trong examples/: tool_call sửa tham số tại chỗ, context trả về message, ctx.ui.custom() trả về component vẽ trực tiếp. Pi chọn sức mạnh, rồi đặt ranh giới ở chỗ khác: tài nguyên của dự án chỉ chạy sau quyết định trust, và ai cần cô lập thì dùng chính extension để có nó (sandbox/ bọc bash bằng sandbox của hệ điều hành, gondolin/ đưa tool vào một micro-VM). Điều này khớp với lập trường của bài viết gốc: mặc định không hỏi quyền (YOLO), ai cần cô lập thì chạy Pi trong container.
TypeScript không cần build, cùng một bản module với host
jiti cho phép một tệp .ts chạy ngay, điều kiện để agent tự viết extension rồi /reload mà không cần toolchain. Virtual module và alias bảo đảm extension import đúng đối tượng mà host đang dùng, kể cả khi Pi là một binary không có node_modules; đây là lý do loader có ba nhánh phân giải thay vì một. Cái giá là biến đổi Babel lúc nạp; Pi dời nó tới lúc có extension đầu tiên cần nạp, và changelog ghi lại các lần giảm chi phí khởi động đó (CHANGELOG.md:631, CHANGELOG.md:825).
Trạng thái sống trong session
Armin gọi khả năng cho extension cất trạng thái vào session là "incredibly powerful". Lý do thấy rõ ở ví dụ todo.ts: vì trạng thái là một phần của cây session, fork, /tree và resume tự động cho trạng thái đúng với điểm lịch sử, không cần extension biết gì về nhánh. Cái giá là extension phải tự dựng lại trạng thái trong session_start và session_tree, và phải chọn đúng nơi cất (details, appendEntry, sendMessage) tuỳ trạng thái có cần vào ngữ cảnh không.
Mỗi sự kiện một luật gộp, không có middleware chung
Một API kiểu middleware (next() cho mọi thứ) đơn giản hơn để mô tả nhưng khó dùng đúng: handler cấp quyền cần "người đầu tiên chặn thắng", handler biến đổi ngữ cảnh cần chuỗi, boundary cần thấy đề xuất của người trước. Pi khai báo kiểu kết quả riêng cho từng sự kiện và cài luật gộp tương ứng trong runner, với hai mặc định có chủ ý: lỗi trong handler thông báo không làm hỏng lần chạy, còn lỗi trong handler cổng thì chặn. Các điểm cho phép ghi thêm entry (boundary) chỉ ở những chỗ transcript chắc chắn hợp lệ, cùng lý do khiến custom message bị hoãn tới cuối lượt (chương AgentSession).
Bật tắt bằng mẫu, không bằng xoá
Mọi tài nguyên đi qua một bộ giải duy nhất, mang enabled và siêu dữ liệu nguồn. pi config chỉ ghi mẫu +/-; gói vẫn được cài, tệp vẫn nằm đó, nên mọi thay đổi đảo ngược được và có thể khác nhau giữa người dùng và dự án. Built-in được biểu diễn bằng đường dẫn tổng hợp chính là để dùng lại bộ máy này thay vì thêm một cơ chế cấu hình thứ hai.
Cạm bẫy và trường hợp biên#
- Cờ boolean của extension nuốt mất prompt. Bộ phân tích tham số chạy trước khi biết cờ nào là boolean, nên
pi --plan "giải thích module auth"lấy câu đó làm giá trị của--plan(args.ts:254; test khẳng định hành vi này ở args.test.ts:533). Sau khi nạp, cờ boolean chỉ được đặttruevà chuỗi bị bỏ (agent-session-services.ts:106). Đặt cờ sau prompt (pi "..." --plan) hoặc viết--plan=1. - Gọi hành động trong factory làm extension hỏng.
pi.sendMessage(),pi.appendEntry(),pi.setActiveTools()ném "Extension runtime not initialized" cho tới khi runner được bind; hãy chuyển chúng vàosession_start. - Tham số đã sửa không hiện ở đâu cả.
tool_execution_startmang tham số gốc (agent-loop.ts:607), transcript ghi tham số gốc, và không có bước kiểm tra schema thứ hai. Nếu mô hình cần biết tham số đã bị đổi, extension phải tự nói trong kết quả. tool_resultkhông thấy mọi kết quả. Lời gọi bị chặn, tool không tồn tại, tham số sai schema đều thành kết quả "immediate" và không đi quaafterToolCall. Một extension che dữ liệu nhạy cảm không thể dựa vàotool_resultđể thấy văn bản do handlertool_callkhác trả về trongreason.- Handler
tool_callném lỗi thì im lặng chặn. Không có thông báo lỗi extension; chỉ có một kết quả tool lỗi mang thông điệp của exception. Khi gỡ lỗi một extension cấp quyền, hãy đọc kết quả tool trong transcript. agent_endchưa phải lúc rảnh. Retry, compaction và tin nhắn xếp hàng có thể mở lần chạy mới sau nó; việc chỉ nên chạy khi Pi thực sự dừng (thông báo, commit) thuộc vềagent_settled(docs/extensions.md:145).- Handler chậm làm chậm luồng. Handler được
awaittuần tự, kể cảmessage_updatevàprovider_stream_event(docs/extensions.md:224). - Nhánh SIGKILL dự phòng của
pi.exec()khó xảy ra. Sau SIGTERM, mã hẹn 5 giây rồi gửi SIGKILL nếuproc.killedcònfalse(exec.ts:52). Theo tài liệu Node,killedthànhtruengay khi tín hiệu được gửi thành công, không phải khi tiến trình đã thoát, nên một tiến trình con bỏ qua SIGTERM có thể giữ lời gọiexecchờ mãi. Ví dụsubagent/dùng cùng mẫu này. sendMessage()lúc đang chạy mặc định là steer. Không truyềntriggerTurn: falsethì message chen vào lần chạy hiện tại thay vì chỉ được ghi lại.- Lệnh trùng tên đổi tên gọi. Hai extension cùng đăng ký
/reviewthì người dùng phải gõ/review:1hoặc/review:2. Còn tool trùng tên với tool dựng sẵn thì thay hẳn tool dựng sẵn, không có chẩn đoán nào, vì kiểm tra xung đột chỉ so giữa các extension với nhau. - Skill lẻ không có
namemang tên thư mục. Tên mặc định là tên thư mục cha của tệp (skills.ts:310), nên hai tệp~/.pi/agent/skills/a.mdvàb.mdcódescriptionnhưng thiếunameđều tên làskills, và tệp thứ hai bị loại vì trùng tên. - Không có
readlẫnbashthì không có skill. Chạypi --tools grep,findlàm mục skill biến mất khỏi system prompt;/skill:tênvẫn dùng được. - Theme nạp lại nóng có điều kiện. Chỉ theme
<agent-dir>/themes/<tên>.jsonđang dùng, trong interactive mode; mọi nguồn khác cần/reload. - Ví dụ
reload-runtime.tskhông làm đúng điều chú thích của nó ở v1.4.0. Tool của nó gọipi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" })(reload-runtime.ts:29). Nhưng từ bản 0.84.2,sendUserMessagechỉ chạy lệnh extension khi cóexpandPromptTemplates: true, mặc định làfalse(agent-session.ts:2453, CHANGELOG.md:889). Theo code, tin nhắn xếp hàng vì thế đến mô hình như văn bản/reload-runtimethay vì chạy lệnh. - Ví dụ
plan-mode/khôi phục trạng thái từ mọi entry. Nó lấy entryplan-modecuối cùng tronggetEntries()(index.ts:349), tức có thể từ một nhánh đã bỏ, trái với khuyến nghị dựng lại từgetBranch()(docs/extensions.md:348). Khi chép ví dụ này, hãy theo cách củatodo.ts. - Chi phí của sub-agent không vào thống kê phiên. Tài liệu dặn tool gọi mô hình lồng nhau phải trả
usagetrong kết quả (docs/extensions.md:249);subagent/chỉ ghi usage của tiến trình con vàodetailsđể hiển thị (index.ts:708). - Chối trust không chặn hết.
AGENTS.md/CLAUDE.mdcủa thư mục vẫn vào system prompt, vàsessionDircủa dự án đã được đọc trước khi hỏi trust.
Đọc code ở đâu#
Thứ tự đề xuất: hợp đồng và ví dụ trước, rồi đường nạp, rồi đường phát sự kiện, cuối cùng là bốn loại tài nguyên còn lại.
| # | Tệp và vùng | Đọc để hiểu |
|---|---|---|
| 1 | packages/coding-agent/docs/extensions.md | Vòng đời, luật gộp handler, tool exposure, trạng thái, UI theo chế độ. Đọc hết, 400 dòng. |
| 2 | packages/coding-agent/examples/extensions/README.md, packages/coding-agent/examples/extensions/permission-gate.ts, packages/coding-agent/examples/extensions/todo.ts | Một cổng tool_call; một tool có trạng thái theo nhánh và UI tuỳ biến. |
| 3 | types.ts:1592, 579, 700 | ExtensionAPI, ToolDefinition, rồi toàn bộ sự kiện và kiểu kết quả. |
| 4 | package-manager.ts:926, 2419 | Mọi tài nguyên đến từ đâu, thứ tự ưu tiên, built-in, cổng trust trong lúc khám phá. |
| 5 | resource-loader.ts:505 | Hai lượt nạp quanh quyết định trust; builtin:; skill, prompt, theme, context file. |
| 6 | loader.ts:160, packages/coding-agent/src/core/extensions/virtual-modules.ts | Runtime với stub, API hai pha, jiti và ba cách phân giải module, cache factory. |
| 7 | packages/coding-agent/src/core/project-trust.ts, packages/coding-agent/src/core/trust-manager.ts | Thứ tự quyết định trust, trust.json, tài nguyên đòi trust. |
| 8 | runner.ts:417, 884, 1036 | Bind hành động thật, ctx và kiểm tra stale, từng hàm phát với luật gộp riêng. |
| 9 | agent-session.ts:644, 1303, 3318 | Hook tool, chuyển sự kiện agent sang extension, session_start, resources_discover, bindCore, _buildRuntime, reload. |
| 10 | agent-loop.ts:518, 869 | Tuần tự hay song song, prepareToolCall, kết quả immediate, afterToolCall. |
| 11 | packages/coding-agent/examples/extensions/plan-mode/index.ts, subagent/index.ts:270 | Ghép nhiều sự kiện thành một tính năng; chạy pi con ở JSON mode. |
| 12 | packages/coding-agent/src/core/skills.ts, packages/coding-agent/docs/skills.md, packages/coding-agent/test/skills.test.ts | Frontmatter, kiểm tra hợp lệ, định dạng trong system prompt; các fixture biên. |
| 13 | packages/coding-agent/src/core/prompt-templates.ts, packages/coding-agent/docs/prompt-templates.md, .pi/prompts/is.md, .pi/prompts/pr.md | Tách tham số, thay chỗ giữ; template thật của repo. |
| 14 | theme.ts:646, packages/coding-agent/src/modes/interactive/theme/theme-schema.ts, packages/coding-agent/docs/themes.md | Theme toàn cục dùng chung giữa các bộ nạp module, watcher, schema. |
| 15 | package-manager-cli.ts:747, packages/coding-agent/docs/packages.md | pi install/remove/update/list/config, trust cho lệnh gói. |
| 16 | packages/coding-agent/src/core/keybindings.ts, packages/coding-agent/docs/keybindings.md, packages/coding-agent/docs/custom-provider.md | Bảng hành động và phím; provider tự viết. |
Tóm tắt#
- Lõi Pi nhỏ có chủ ý; plan mode, sub-agent, todo, hỏi quyền được dựng lại bằng extension, và cả MCP, codemode, tool search, llama.cpp cũng là extension dựng sẵn
builtin:<name>, tắt hoặc thay được. System prompt chỉ cho mô hình chỗ tài liệu của Pi để nó tự viết extension, và/reloadnạp lại mã mới. - Extension là module TypeScript export một factory nhận
pi: ExtensionAPI. jiti dịch nó lúc chạy; virtual module hoặc alias bảo đảm import của extension trỏ vào đúng module của host trong cả ba kiểu chạy (mã nguồn, gói npm, binary). - Factory chạy trong trạng thái
loading: đăng ký được ghi vào đối tượngExtension, thay đổi lên runtime chung được xếp hàng tới lúc commit, và hành động lên phiên chỉ dùng được sau khiAgentSessionbind runner. - Mọi tài nguyên đi qua
DefaultPackageManager.resolve(), với hạng ưu tiên dự án trước người dùng, mục settings trước thư mục tự khám phá, gói rồi built-in sau cùng;-eđứng đầu. Thứ tự này cũng là thứ tự extension nhận sự kiện. - Project trust chia việc nạp làm hai lượt: extension của người dùng và CLI nạp trước, có thể quyết định trust qua
project_trust; mọi thứ dưới.pi/và gói của dự án chỉ nạp khi được tin cậy. Context file vẫn nạp bất kể trust. - Runner phát sự kiện theo thứ tự nạp trên một bản chụp handler, với luật gộp riêng từng sự kiện: thông báo, sửa tại chỗ, chuỗi biến đổi, cổng, boundary. Lỗi handler được báo rồi bỏ qua, trừ
tool_callvàuser_bash, nơi lỗi nghĩa là chặn. tool_callđi qua hookbeforeToolCall:event.inputlà bản sao đã kiểm tra schema, sửa tại chỗ thì tool chạy với tham số mới còn transcript giữ tham số gốc;blockhay một exception thành kết quả tool lỗi ngay.tool_resultchỉ thấy lời gọi đã thực sự chạy.- Trạng thái của extension nên sống trong session:
detailscủa kết quả tool khi phải đi theo nhánh,appendEntrykhi không vào ngữ cảnh,sendMessagekhi mô hình cần thấy; dựng lại từgetBranch()trongsession_start. - Skill công bố dần: system prompt chỉ có tên, mô tả, đường dẫn; mô hình đọc
SKILL.mdkhi cần, hoặc người dùng ép bằng/skill:tên. Prompt template là Markdown với$1,$@,${1:-mặc định}. Theme là JSON có schema chặt, nạp lại ngay khi sửa tệp của người dùng. - Pi package đóng gói bốn loại tài nguyên qua npm, git hoặc đường dẫn; manifest
pitrongpackage.jsonchọn tệp, bộ lọc trong settings thu hẹp, phiên bản npm chính xác và ref git thì được ghim, vàpi configbật tắt bằng mẫu+/-thay vì xoá.
12Các chế độ chạy: interactive, print, JSON, RPC, SDK#
Vai trò: năm giao diện, một cơ chế#
Cùng một Pi được dùng theo năm cách. Một người gõ trong terminal (interactive). Một script chạy pi -p rồi đọc câu trả lời cuối (print). Một tiến trình đọc luồng sự kiện (event stream) có cấu trúc của một lần chạy (JSON). Một ứng dụng khác, viết bằng ngôn ngữ bất kỳ, điều khiển Pi lâu dài qua stdin/stdout (RPC). Hoặc một chương trình Node.js/Bun nhúng thẳng Pi vào tiến trình của nó (SDK). Tài liệu “How Pi Works” tóm lại quan hệ giữa chúng bằng một câu ngắn ở cuối mục Interfaces:
## InterfacesInteractive mode renders session and agent events in the terminal. Print mode runs a prompt and writes the final response. JSON mode writes agent events as JSONL.RPC mode accepts JSONL commands on stdin and writes responses and events to stdout. The TypeScript SDK creates and controls agent sessions in process.All interfaces use the same agent and session mechanisms.Câu đó đúng theo nghĩa đen trong code. Bốn chế độ CLI đều nhận cùng một đối tượng AgentSessionRuntime do main() dựng, và đều nói chuyện với cùng một AgentSession (chương AgentSession), vốn bọc cùng một Agent (chương pi-agent-core). SDK bỏ qua main() nhưng gọi đúng các hàm mà main() gọi. Khác biệt giữa các chế độ chỉ nằm ở bốn chỗ: đầu vào đi vào thế nào, sự kiện được vẽ hay tuần tự hoá ra sao, extension nhận giao diện người dùng nào, và tiến trình sống bao lâu. Chương này đi qua đúng bốn chỗ đó. Phần bên trong AgentSession thuộc về chương AgentSession, cách TUI vẽ thuộc về chương pi-tui, API của extension thuộc về chương Extensions.
| Chế độ | Kích hoạt | Hàm chạy | Vòng đời | Extension thấy |
|---|---|---|---|---|
| Interactive | stdin và stdout đều là TTY, không có -p/--mode json|rpc | new InteractiveMode(runtime).run() | tới khi người dùng thoát | mode: "tui", hasUI: true |
-p, hoặc stdin/stdout bị chuyển hướng | runPrintMode(runtime, { mode: "text" }) | một lần | "print", hasUI: false | |
| JSON | --mode json | runPrintMode(runtime, { mode: "json" }) | một lần | "json", hasUI: false |
| RPC | --mode rpc | runRpcMode(runtime) | tới khi stdin đóng | "rpc", hasUI: true |
| SDK | import { createAgentSession } | không qua main() | do host quyết | mặc định "print", không UI, trừ khi host truyền vào |
Mô hình tư duy: mode là lớp vỏ quanh AgentSessionRuntime#
Mọi lần chạy lệnh pi đi qua cùng một đường khởi động, và chỉ rẽ nhánh ở bước cuối:
pi [options] [@files] [messages] cli.ts → main(args)
│
├─ lệnh con auth / install / remove / update / list / config / mcp → chạy rồi thoát
├─ parseArgs(args) → Args cli/args.ts
├─ resolveAppMode(parsed, stdin.isTTY, stdout.isTTY)
│ → "interactive" | "print" | "json" | "rpc"
├─ takeOverStdout() nếu không phải interactive core/output-guard.ts
├─ createSessionManager(parsed, …) session mới, -c, -r, --session, --fork
├─ createRuntime: factory dựng services theo cwd + AgentSession
├─ createAgentSessionRuntime(createRuntime, { cwd, agentDir, sessionManager })
│
└─ rẽ nhánh theo appMode
rpc → runRpcMode(runtime)
interactive → new InteractiveMode(runtime, …).run()
print, json → runPrintMode(runtime, { mode: "text" | "json", … })
main(). Ba hàm chạy ở cuối là “lớp vỏ”; mọi thứ phía trên là chung.ExtensionUIContext. InteractiveMode, runPrintMode, runRpcMode, hoặc host SDK./new, /resume, /fork, import.- Runtime host
- Tên tham số mà các mode dùng cho
AgentSessionRuntime. Mode đọc lạiruntimeHost.sessionmỗi khi session bị thay (qua callback rebind), vì đối tượngAgentSessioncũ khi đó đã bị dispose. - Services
AgentSessionServices:cwd,agentDir,ModelRuntime,SettingsManager,ResourceLoader, cùng các chẩn đoán (diagnostics) thu được khi dựng.- Binding
- Lời gọi
session.bindExtensions(...)mà mỗi mode thực hiện để đưa cho extension một giao diện người dùng, một tên mode và các hành động như “tạo session mới”. - AgentSessionEvent
- Sự kiện mà
session.subscribe()phát: mọiAgentEventcủa vòng lặp cộng với sự kiện cấp session nhưagent_settled,queue_update,compaction_start/end,auto_retry_start/end(agent-session.ts:196–237). - agent_end và agent_settled
agent_endđóng một lần chạy cấp thấp củaAgent.agent_settlednghĩa là Pi không còn việc tự động nào cho lần chạy cấp session đó: không thử lại, không compaction rồi chạy tiếp, không tin nhắn chờ (json.md:48).
Từ lệnh pi tới một runtime#
File thực thi chỉ có sáu dòng:
#!/usr/bin/env nodeimport { setupCli } from "./cli/setup.ts";import { main } from "./main.ts";setupCli();main(process.argv.slice(2));setupCli() đặt tên tiến trình, biến môi trường PI_CODING_AGENT và AI_AGENT, tắt cảnh báo của Node và cấu hình HTTP dispatcher (setup.ts:4–13). Một điểm vào thứ hai, rpc-entry.ts, làm y hệt nhưng chèn sẵn --mode rpc (rpc-entry.ts:13).main() xử lý các lệnh con trước khi đọc tuỳ chọn: pi auth ..., các lệnh gói (install, remove, update, list), pi config, pi mcp ... (main.ts:585–621). Chỉ khi không phải lệnh con, nó mới gọi parseArgs(). Bộ phân tích là một vòng for viết tay; hai tuỳ chọn quyết định chế độ trông như sau:
} else if (arg === "--mode") { const mode = args[i + 1]; if (mode === undefined || mode.startsWith("-")) { result.diagnostics.push({ type: "error", message: "--mode requires text, json, or rpc" }); continue; } i++; if (mode !== "text" && mode !== "json" && mode !== "rpc") { result.diagnostics.push({ type: "error", message: `Invalid mode "${mode}". Valid values: text, json, rpc`, }); continue; } result.mode = mode;} else if (arg === "--print" || arg === "-p") { result.print = true; const next = args[i + 1]; if (next !== undefined && !next.startsWith("@") && (!next.startsWith("-") || next.startsWith("---"))) { result.messages.push(next); i++; }unknownFlags để extension đăng ký cờ riêng (args.ts:249–262); tuỳ chọn ngắn không biết tên thì báo lỗi.Chế độ cuối cùng do một hàm nhỏ quyết định, theo thứ tự ưu tiên:
function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode { if (parsed.mode === "rpc") { return "rpc"; } if (parsed.mode === "json") { return "json"; } if (parsed.print || !stdinIsTTY || !stdoutIsTTY) { return "print"; } return "interactive";}Hai hệ quả đáng nhớ. Chỉ cần stdin hoặc stdout không phải terminal là Pi chọn print, nên git diff | pi "Review" và pi "..." > out.txt chạy được mà không cần -p. Và --mode text không ép chạy một lần: trong terminal, nó vẫn mở TUI (cli.md:41). Ngay sau khi chọn xong, mọi chế độ không tương tác chiếm lấy stdout:
let appMode = resolveAppMode(parsed, process.stdin.isTTY, process.stdout.isTTY);const shouldTakeOverStdout = appMode !== "interactive" && !isPlainRuntimeMetadataCommand(parsed);if (shouldTakeOverStdout) { takeOverStdout();}if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) { console.error(chalk.red("Error: @file arguments are not supported in RPC mode")); process.exit(1);}Tiếp theo là session. createSessionManager() chọn một session trong bộ nhớ (--no-session), fork (tách nhánh) một session có sẵn (--fork), mở theo đường dẫn hoặc id (--session, có hỏi fork nếu session thuộc dự án khác), hiện bộ chọn (-r), tiếp tục session gần nhất (-c), hoặc tạo mới (main.ts:358–449). Bước này đi trước mọi service, vì chú thích ở main.ts:686–690 giải thích: session được chọn có thể thuộc một thư mục làm việc khác, và settings, resource, provider (nhà cung cấp) của dự án phải được tính theo thư mục đó.
Rồi main() định nghĩa một factory, tức một hàm dựng runtime cho một cwd và một session manager bất kỳ:
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, agentDir, sessionManager, sessionStartEvent, projectTrustContext,}) => { const isInitialRuntime = sessionStartEvent === undefined; const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); const services = await createAgentSessionServices({ cwd, agentDir, settingsManager: runtimeSettingsManager, modelRuntimeSignal: AbortSignal.timeout(15_000), extensionFlagValues: parsed.unknownFlags, const created = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent, model: sessionOptions.model, thinkingLevel: sessionOptions.thinkingLevel, scopedModels: sessionOptions.scopedModels, tools: sessionOptions.tools, excludeTools: sessionOptions.excludeTools, noTools: sessionOptions.noTools, customTools: sessionOptions.customTools, }); const cliThinkingOverride = parsed.thinking !== undefined || cliThinkingFromModel; if (created.session.model && cliThinkingOverride) { created.session.setThinkingLevel(created.session.thinkingLevel); } return { ...created, services, diagnostics, };};Factory đóng gói (closure) mọi đầu vào cố định của tiến trình (các cờ CLI, đường dẫn resource đã chuyển thành tuyệt đối, kho trust dự án) và mỗi lần được gọi thì: quyết định trust cho cwd đó, dựng SettingsManager, gọi createAgentSessionServices(), giải mô hình (model) và bộ tool từ cờ CLI (buildSessionOptions(), main.ts:451–554), đặt --api-key thành khoá tạm không lưu, rồi gọi createAgentSessionFromServices(), một lớp mỏng quanh createAgentSession() của SDK (agent-session-services.ts:214–233). Phần cuối của main() dùng factory đó và rẽ nhánh:
const runtime = await createAgentSessionRuntime(createRuntime, { cwd: sessionManager.getCwd(), agentDir, sessionManager,});time("createAgentSessionRuntime");const { services, session, modelFallbackMessage } = runtime;// Read piped stdin content (if any) - skip for RPC mode which uses stdin for JSON-RPClet stdinContent: string | undefined;if (appMode !== "rpc") { stdinContent = await readPipedStdin(); if (stdinContent !== undefined && appMode === "interactive") { appMode = "print"; }}if (appMode === "rpc") { printTimings(); await runRpcMode(runtime);} else if (appMode === "interactive") { const interactiveMode = new InteractiveMode(runtime, { migratedProviders, startupDiagnostics, modelFallbackMessage, autoTrustOnReloadCwd, initialMessage, initialImages, initialMessages: parsed.messages, verbose: parsed.verbose, tuiMode: parsed.tuiMode, initialThemeSetting: parsed.useTheme, });} else { printTimings(); const exitCode = await runPrintMode(runtime, { mode: toPrintOutputMode(appMode), messages: parsed.messages, initialMessage, initialImages, });Nếu stdin bị pipe, toàn bộ nội dung được đọc trước (readPipedStdin) và nối vào đầu prompt đầu tiên cùng nội dung các file @path (initial-message.ts:20–43); một phiên đang định mở TUI sẽ chuyển sang print (dòng 898–900). RPC không đọc stdin ở bước này, vì stdin của nó chở lệnh. Trước khi rẽ nhánh, các chế độ không tương tác in chẩn đoán ra stderr, thoát với mã 1 nếu có lỗi, và đòi phải có mô hình (main.ts:915–931).
AgentSessionRuntime và services#
Vấn đề. Một session gắn với một thư mục làm việc: settings của dự án, các file AGENTS.md, extension của dự án, provider mà extension đó đăng ký, quyết định trust. Khi người dùng /resume một session của dự án khác, hoặc fork, mọi thứ đó phải được dựng lại cho thư mục mới, và các extension context cũ phải bị vô hiệu hoá. Nếu từng mode tự làm việc này, bốn chế độ sẽ có bốn cách thay session hơi khác nhau.
Giải pháp. Gom mọi thứ phụ thuộc cwd vào một gói services, và giao việc thay session cho một đối tượng duy nhất giữ factory:
/** * Coherent cwd-bound runtime services for one effective session cwd. * * This is infrastructure only. The AgentSession itself is created separately so * session options can be resolved against these services first. */export interface AgentSessionServices { cwd: string; agentDir: string; modelRuntime: ModelRuntime; settingsManager: SettingsManager; resourceLoader: ResourceLoader; diagnostics: AgentSessionRuntimeDiagnostic[];}Kiểu của factory nhận cwd, agentDir, session manager, và tuỳ chọn một sessionStartEvent cho extension biết vì sao session bắt đầu (agent-session-runtime.ts:35–41). AgentSessionRuntime giữ session hiện tại, services và chính factory đó; mọi thao tác thay session đi theo cùng một khuôn:
async switchSession( sessionPath: string, options?: { cwdOverride?: string; withSession?: (ctx: ReplacedSessionContext) => Promise<void>; projectTrustContextFactory?: (cwd: string) => ProjectTrustContext; },): Promise<{ cancelled: boolean }> { const beforeResult = await this.emitBeforeSwitch("resume", sessionPath); if (beforeResult.cancelled) { return beforeResult; } const previousSessionFile = this.session.sessionFile; const sessionManager = SessionManager.open(sessionPath, undefined, options?.cwdOverride); assertSessionCwdExists(sessionManager, this.cwd); await this.teardownCurrent("resume", sessionManager.getSessionFile()); this.apply( await this.createRuntime({ cwd: sessionManager.getCwd(), agentDir: this.services.agentDir, sessionManager, sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()), }), ); await this.finishSessionReplacement(options?.withSession); return { cancelled: false };}- Hỏi extension
session_before_switchcó thể huỷ thao tác. - Tháo session cũ
teardownCurrent():abort()để lượt (turn) đang chạy được lưu, phátsession_shutdown, rồidispose(). - Dựng lạiGọi factory với cwd của session đích và
session_startlý do"resume". - RebindGọi callback mà mode đã đăng ký bằng
setRebindSession().
Bước cuối là chỗ mode và runtime gặp nhau. Mỗi mode đăng ký một hàm để đăng ký lại listener và gắn lại extension vào session mới: print mode ở print-mode.ts:70–72, RPC ở rpc-mode.ts:313–315, interactive ở interactive-mode.ts:619–622. teardownCurrent() gọi abort() trước tiên, và chú thích giải thích lý do: lượt đang dở, kể cả kết quả tool, phải được lưu vào session cũ trước khi nó bị thay (agent-session-runtime.ts:167–178).
Mỗi chế độ gắn extension theo cách riêng#
Extension không biết mình đang chạy trong chế độ nào cho tới khi mode gọi bindExtensions(). Lời gọi này nhận một uiContext, một tên mode và các hành động lệnh, rồi mới phát session_start cho extension:
async bindExtensions(bindings: ExtensionBindings): Promise<void> { if (bindings.uiContext !== undefined) { this._extensionUIContext = bindings.uiContext; } if (bindings.mode !== undefined) { this._extensionMode = bindings.mode; } if (bindings.commandContextActions !== undefined) { this._extensionCommandContextActions = bindings.commandContextActions; } if (bindings.abortHandler !== undefined) { this._extensionAbortHandler = bindings.abortHandler; } if (bindings.shutdownHandler !== undefined) { this._extensionShutdownHandler = bindings.shutdownHandler; } if (bindings.onError !== undefined) { this._extensionErrorListener = bindings.onError; } this._applyExtensionBindings(this._extensionRunner); await this._extensionRunner.emit(this._sessionStartEvent); this._extensionRunner.reportUnhandledMcpServers(); await this.extendResourcesFromExtensions(this._sessionStartEvent.reason === "reload" ? "reload" : "startup");}Tên mode là một trong bốn giá trị "tui" | "rpc" | "json" | "print", và extension context có thêm cờ hasUI (extensions/types.ts:323–331). Khi mode không đưa uiContext, extension runner dùng một context rỗng:
const noOpUIContext: ExtensionUIContext = { select: async () => undefined, confirm: async () => false, input: async () => undefined, notify: () => {}, onTerminalInput: () => () => {}, setStatus: () => {}, setWorkingMessage: () => {}, setWorkingVisible: () => {}, setWorkingIndicator: () => {}, setHiddenThinkingLabel: () => {}, setWidget: () => {}, setFooter: () => {}, setHeader: () => {}, setTitle: () => {}, setUIContext(uiContext?: ExtensionUIContext, mode: ExtensionMode = "print"): void { this.uiContext = uiContext ? this.wrapUIPromptContext(uiContext) : noOpUIContext; this.mode = mode; } hasUI(): boolean { return this.uiContext !== noOpUIContext; }| Chế độ | mode | uiContext | hasUI | ctx.ui.confirm(...) trả về |
|---|---|---|---|---|
| Interactive | "tui" | hộp thoại thật trong TUI (interactive-mode.ts:2633) | true | lựa chọn của người dùng |
| RPC | "rpc" | giao thức con extension_ui_* | true | câu trả lời của client |
| JSON | "json" | không có, dùng noOpUIContext | false | false ngay lập tức |
"print" | không có, dùng noOpUIContext | false | false ngay lập tức | |
| SDK | mặc định "print" (agent-session.ts:461) | do host truyền | tuỳ host | tuỳ host |
Vì vậy extension viết cẩn thận luôn xét ctx.hasUI. Ví dụ permission-gate.ts hỏi người dùng trước khi chạy rm -rf hay sudo, nhưng khi không có UI thì chặn luôn thay vì im lặng cho qua (permission-gate.ts:19–29).
Print mode#
Print mode là người dùng đơn giản nhất của runtime, và cũng là nơi dễ thấy nhất rằng một mode chỉ là lớp vỏ. runPrintMode() phục vụ cả -p (text) lẫn --mode json; hai chế độ chỉ khác nhau ở chỗ sự kiện có được ghi ra hay không. Mỗi lần gắn session, nó gắn extension với tên mode tương ứng và đăng ký listener:
const rebindSession = async (): Promise<void> => { session = runtimeHost.session; await session.bindExtensions({ mode: mode === "json" ? "json" : "print", commandContextActions: { onError: (err) => { console.error(`Extension error (${err.extensionPath}): ${err.error}`); }, }); unsubscribe?.(); unsubscribeBackpressure?.(); unsubscribe = session.subscribe((event) => { if (mode === "json") { writeRawStdout(`${JSON.stringify(toJsonEvent(event))}\n`); } }); unsubscribeBackpressure = mode === "json" ? session.agent.subscribe(async () => { await waitForRawStdoutBackpressure(); }) : undefined;};Phần thân chỉ là gửi các prompt lần lượt rồi, với text, in khối văn bản của assistant message cuối cùng:
try { if (mode === "json") { const header = session.sessionManager.getHeader(); if (header) { writeRawStdout(`${JSON.stringify(header)}\n`); } } await rebindSession(); if (initialMessage) { await session.prompt(initialMessage, { images: initialImages }); } for (const message of messages) { await session.prompt(message); } if (mode === "text") { const state = session.state; const lastMessage = state.messages[state.messages.length - 1]; if (lastMessage?.role === "assistant") { const assistantMsg = lastMessage as AssistantMessage; if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") { console.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`); exitCode = 1; } else { for (const content of assistantMsg.content) { if (content.type === "text") { writeRawStdout(`${content.text}\n`); } } if (assistantMsg.stopReason === "length") { console.error("Response was truncated: the model reached its output token limit."); exitCode = 1; } } } }Mỗi await session.prompt() chỉ resolve khi lần chạy đã xong, kể cả các lần thử lại tự động (sdk.md:64), nên các prompt trong pi -p "a" "b" chạy nối tiếp nhau trên cùng một session. Với text, chỉ các khối text của message cuối được in, mỗi khối một dòng; khối thinking và lời gọi tool không bao giờ ra stdout. Mã thoát phản ánh stopReason: "error" hoặc "aborted" in lỗi ra stderr và trả 1; "length" vẫn in phần văn bản bị cụt nhưng cảnh báo và trả 1. JSON mode không đặt mã thoát theo kết quả của mô hình; người đọc phải tự xem sự kiện (cli-integration.md:46).
Phần còn lại của hàm là vệ sinh tiến trình: SIGTERM và SIGHUP giết các tiến trình con chạy tách rời mà Pi theo dõi, dispose runtime rồi thoát với mã 143 hoặc 129 (print-mode.ts:50–66); khối finally luôn dispose runtime (phát session_shutdown lý do "quit") và xả hết stdout trước khi trả về.
JSON event mode và stdout dành riêng#
pi --mode json "..." ghi một dòng header của session, rồi mỗi sự kiện session một dòng JSON, rồi thoát khi các prompt xong (json.md:9). Hình dạng sự kiện trên dây gần như y hệt AgentSessionEvent trong tiến trình, trừ một loại:
type WithoutPartial<T> = T extends { partial: unknown } ? Omit<T, "partial"> : T;type ToJsonAssistantMessageEvent<T> = T extends { type: "toolcall_start"; partial: unknown } ? WithoutPartial<T> & { id: string; toolName: string } : WithoutPartial<T>;type MessageUpdateEvent = Extract<AgentSessionEvent, { type: "message_update" }>;type JsonMessageUpdateEvent = { type: "message_update"; usage: Usage; assistantMessageEvent: ToJsonAssistantMessageEvent<MessageUpdateEvent["assistantMessageEvent"]>;};/** Session event shape emitted by the JSON and RPC stdout protocols. */export type JsonAgentSessionEvent = Exclude<AgentSessionEvent, { type: "message_update" }> | JsonMessageUpdateEvent;/** * Remove cumulative assistant snapshots from streaming wire events. * `message_start` provides the initial message, deltas build it, and * `message_end` provides the final authoritative message. Cumulative usage, * tool-call ids, and tool names remain available because their size is constant. */export function toJsonEvent(event: MessageUpdateEvent): JsonMessageUpdateEvent;export function toJsonEvent(event: AgentSessionEvent): JsonAgentSessionEvent;export function toJsonEvent(event: AgentSessionEvent): JsonAgentSessionEvent { if (event.type !== "message_update") { return event; } if (event.message.role !== "assistant") { throw new Error("message_update message is not an assistant message"); } return { type: "message_update", usage: event.message.usage, assistantMessageEvent: toJsonAssistantMessageEvent(event.assistantMessageEvent), };}Vì sao. Trong tiến trình, mỗi message_update mang theo toàn bộ assistant message đang lớn dần, và mỗi sự kiện con của pi-ai còn mang partial là chính message đó. Với một câu trả lời N mảnh, tuần tự hoá nguyên si nghĩa là ghi ra tổng cộng cỡ N² ký tự. Từ 0.84.0, JSON và RPC chỉ ghi phần delta: message_start cho message ban đầu, các delta dựng dần, message_end là bản cuối có thẩm quyền (CHANGELOG.md:976). Hai thứ có kích thước cố định được giữ lại: usage tích luỹ, và id/toolName gắn vào toolcall_start vì bỏ partial thì chúng không còn chỗ nào khác.
Ví dụ minh hoạ: pi --mode json "Liệt kê file" (rút gọn)
{"type":"session","version":3,"id":"0199…","timestamp":"2026-10-22T05:00:00.000Z","cwd":"/work/app"}
{"type":"agent_start"}
{"type":"turn_start"}
{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"Liệt kê file"}],…}}
{"type":"message_end","message":{"role":"user",…}}
{"type":"message_start","message":{"role":"assistant","content":[],"stopReason":"pending",…}}
{"type":"message_update","usage":{…},"assistantMessageEvent":{"type":"toolcall_start","contentIndex":0,"id":"call_1","toolName":"bash"}}
{"type":"message_update","usage":{…},"assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"{\"command\":\"ls\"}"}}
…
{"type":"agent_end","messages":[…],"willRetry":false}
{"type":"agent_settled","aborted":false}
version 3 là CURRENT_SESSION_VERSION, session-manager.ts:41); RPC không có dòng này.Stdout chỉ dành cho giao thức
Một dòng console.log lạc từ extension hay thư viện nào đó sẽ làm hỏng luồng JSONL. Vì vậy, ngay khi chọn chế độ không tương tác, main() gọi takeOverStdout():
export function takeOverStdout(): void { if (stdoutTakeoverState) { return; } const rawStdoutWrite = process.stdout.write.bind(process.stdout) as StdoutTakeoverState["rawStdoutWrite"]; const rawStderrWrite = process.stderr.write.bind(process.stderr) as StdoutTakeoverState["rawStderrWrite"]; const originalStdoutWrite = process.stdout.write; process.stdout.write = (( chunk: string | Uint8Array, encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), callback?: (error?: Error | null) => void, ): boolean => { if (typeof encodingOrCallback === "function") { return rawStderrWrite(String(chunk), encodingOrCallback); } return rawStderrWrite(String(chunk), callback); }) as typeof process.stdout.write; stdoutTakeoverState = { rawStdoutWrite, rawStderrWrite, originalStdoutWrite, };}Sau lời gọi này, process.stdout.write thực chất ghi vào stderr. Chỉ còn một đường tới stdout thật, và đường đó tuần tự hoá mọi lần ghi:
export function writeRawStdout(text: string): void { if (text.length === 0) { return; } rawStdoutWriteTail = rawStdoutWriteTail.then(() => writeRawStdoutChunk(text)); void rawStdoutWriteTail.catch(() => { process.exit(1); });}export async function waitForRawStdoutBackpressure(): Promise<void> { while (true) { const tail = rawStdoutWriteTail; await tail; if (tail === rawStdoutWriteTail) { return; } }}Mỗi bản ghi được nối vào một chuỗi promise (dòng 89), nên hai bản ghi không bao giờ xen vào nhau; lỗi tạm thời như EAGAIN hay ENOBUFS được thử lại sau 10 ms, còn lỗi thật làm tiến trình thoát (output-guard.ts:20–43). Đó là một sửa lỗi ở 0.76.0 (CHANGELOG.md:2023).
RPC mode: giao thức JSONL hai chiều#
RPC mode biến Pi thành một tiến trình con sống lâu: client ghi lệnh vào stdin, Pi ghi phản hồi và sự kiện ra stdout. Giao thức có bốn họ bản ghi (rpc.md:24–31):
| Hướng | Bản ghi | Kiểu trong rpc-types.ts |
|---|---|---|
| stdin | Lệnh | RpcCommand |
| stdout | Phản hồi { type: "response" } | RpcResponse |
| stdout | Sự kiện session | JsonAgentSessionEvent (cùng hình dạng với JSON mode), cộng extension_error |
| cả hai | Extension UI | RpcExtensionUIRequest (stdout), RpcExtensionUIResponse (stdin) |
Data model của giao thức
export type RpcCommand = // Prompting | { id?: string; type: "prompt"; message: string; images?: ImageContent[]; streamingBehavior?: "steer" | "followUp" } | { id?: string; type: "steer"; message: string; images?: ImageContent[] } | { id?: string; type: "follow_up"; message: string; images?: ImageContent[] } | { id?: string; type: "abort" } | { id?: string; type: "clear_queue" } | { id?: string; type: "new_session"; parentSession?: string } // State | { id?: string; type: "get_state" } // Model | { id?: string; type: "set_model"; provider: string; modelId: string } | { id?: string; type: "cycle_model" } | { id?: string; type: "get_available_models" } // Thinking | { id?: string; type: "set_thinking_level"; level: ThinkingLevel } | { id?: string; type: "cycle_thinking_level" } | { id?: string; type: "get_available_thinking_levels" } // Queue modes | { id?: string; type: "set_steering_mode"; mode: "all" | "one-at-a-time" } | { id?: string; type: "set_follow_up_mode"; mode: "all" | "one-at-a-time" } // Compaction | { id?: string; type: "compact"; customInstructions?: string } | { id?: string; type: "set_auto_compaction"; enabled: boolean } // Retry | { id?: string; type: "set_auto_retry"; enabled: boolean } | { id?: string; type: "abort_retry" } // Bash | { id?: string; type: "bash"; command: string; excludeFromContext?: boolean } | { id?: string; type: "abort_bash" } // Session | { id?: string; type: "get_session_stats" } | { id?: string; type: "export_html"; outputPath?: string } | { id?: string; type: "switch_session"; sessionPath: string } | { id?: string; type: "fork"; entryId: string } | { id?: string; type: "clone" } | { id?: string; type: "get_fork_messages" } | { id?: string; type: "get_entries"; since?: string } | { id?: string; type: "get_tree" } | { id?: string; type: "get_last_assistant_text" } | { id?: string; type: "set_session_name"; name: string } // Messages | { id?: string; type: "get_messages" } // Commands (available for invocation via prompt) | { id?: string; type: "get_commands" };id tuỳ chọn để ghép với phản hồi. Bốn lệnh đầu tương ứng trực tiếp với prompt(), steer(), followUp(), abort() của AgentSession.Phản hồi là một hợp lớn, mỗi lệnh một nhánh thành công có kiểu data riêng, cộng một nhánh lỗi chung cho mọi lệnh:
export type RpcResponse = // Prompting | { id?: string; type: "response"; command: "prompt"; success: true; data: { disposition: PromptDisposition } } | { id?: string; type: "response"; command: "steer"; success: true; data: { disposition: QueuedInputDisposition } } | { id?: string; type: "response"; command: "follow_up"; success: true; data: { disposition: QueuedInputDisposition }; } | { id?: string; type: "response"; command: "abort"; success: true } | { id?: string; type: "response"; command: "clear_queue"; success: true; data: { steering: string[]; followUp: string[] }; } | { id?: string; type: "response"; command: "new_session"; success: true; data: { cancelled: boolean } } | { id?: string; type: "response"; command: "get_commands"; success: true; data: { commands: RpcSlashCommand[] }; } // Error response (any command can fail) | { id?: string; type: "response"; command: string; success: false; error: string };disposition trong phản hồi của prompt cho biết prompt đã đi đâu: "started" (bắt đầu một lần chạy), "queued" (đang chạy nên được xếp hàng làm steer hoặc follow-up), hay "handled" (một lệnh extension hoặc input handler đã tiêu thụ nó, không có lần chạy nào) (agent-session.ts:311–312, rpc-commands.md:40). Trạng thái mà get_state trả về là một ảnh chụp phẳng của session:
export interface RpcSessionState { model?: Model<any>; thinkingLevel: ThinkingLevel; isStreaming: boolean; isCompacting: boolean; steeringMode: "all" | "one-at-a-time"; followUpMode: "all" | "one-at-a-time"; sessionFile?: string; sessionId: string; sessionName?: string; autoCompactionEnabled: boolean; messageCount: number; pendingMessageCount: number;}Khung bản ghi: JSONL nghiêm ngặt
/** * Serialize a single strict JSONL record. * * Framing is LF-only. Payload strings may contain other Unicode separators such as * U+2028 and U+2029. Clients must split records on `\n` only. */export function serializeJsonLine(value: unknown): string { return `${JSON.stringify(value)}\n`;}/** * Attach an LF-only JSONL reader to a stream. * * This intentionally does not use Node readline. Readline splits on additional * Unicode separators that are valid inside JSON strings and therefore does not * implement strict JSONL framing. */export function attachJsonlLineReader(stream: Readable, onLine: (line: string) => void): () => void { const decoder = new StringDecoder("utf8"); let buffer = ""; const emitLine = (line: string) => { onLine(line.endsWith("\r") ? line.slice(0, -1) : line); }; const onData = (chunk: string | Buffer) => { buffer += typeof chunk === "string" ? chunk : decoder.write(chunk); while (true) { const newlineIndex = buffer.indexOf("\n"); if (newlineIndex === -1) { return; } emitLine(buffer.slice(0, newlineIndex)); buffer = buffer.slice(newlineIndex + 1); } };Chi tiết này nghe vụn vặt nhưng từng làm hỏng giao thức. JSON.stringify không thoát hai ký tự U+2028 (line separator) và U+2029 (paragraph separator), nên chúng có thể nằm nguyên trong một chuỗi JSON hợp lệ. readline của Node lại coi chúng là ký tự xuống dòng và cắt bản ghi làm đôi. Từ 0.57.0, cả hai phía dùng bộ đọc tự viết chỉ tách ở \n (CHANGELOG.md:3373), và tài liệu cấm client dùng readline (rpc.md:52–54).
Phía server: runRpcMode()
runRpcMode() chiếm stdout, định nghĩa hàm output() ghi một bản ghi JSONL qua writeRawStdout, cùng hai hàm dựng phản hồi success() và error() (rpc-mode.ts:54–77). Việc gắn session giống print mode, nhưng có UI thật và một kênh báo lỗi extension:
const rebindSession = async (): Promise<void> => { session = runtimeHost.session; await session.bindExtensions({ uiContext: createExtensionUIContext(), mode: "rpc", commandContextActions: { shutdownHandler: () => { shutdownRequested = true; }, onError: (err) => { output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error }); }, }); unsubscribe?.(); unsubscribeBackpressure?.(); unsubscribe = session.subscribe((event) => { output(toJsonEvent(event)); if (event.type === "agent_settled") { void checkShutdownRequested(); } }); unsubscribeBackpressure = session.agent.subscribe(async () => { await waitForRawStdoutBackpressure(); });};Mọi sự kiện session đều được ghi ra (dòng 355–356), và agent_settled còn là điểm kiểm tra yêu cầu tắt từ extension. Bộ xử lý lệnh là một switch lớn; phần thú vị nhất là bốn lệnh đầu:
case "prompt": { // Start prompt handling immediately, but emit the authoritative response only after // prompt preflight succeeds. Queued and immediately handled prompts also count as success. let preflightSucceeded = false; void session .prompt(command.message, { images: command.images, streamingBehavior: command.streamingBehavior, source: "rpc", preflightResult: (disposition) => { preflightSucceeded = true; output(success(id, "prompt", { disposition })); }, }) .catch((e) => { if (!preflightSucceeded) { output(error(id, "prompt", e.message)); } }); return undefined;}case "steer": { const disposition = await session.steer(command.message, command.images, { source: "rpc" }); return success(id, "steer", { disposition });}case "follow_up": { const disposition = await session.followUp(command.message, command.images, { source: "rpc" }); return success(id, "follow_up", { disposition });}case "abort": { await session.abort(); return success(id, "abort");}prompt là lệnh duy nhất không chờ. Một prompt được chấp nhận có thể chạy nhiều phút, và client cần biết sớm rằng nó đã được nhận. Vì vậy session.prompt() được khởi động mà không await, và phản hồi được ghi đúng một lần qua callback preflightResult: khi lệnh extension đã xử lý, khi prompt được xếp hàng, hoặc ngay trước khi lần chạy bắt đầu. Nếu prompt bị từ chối trước bước đó (không có mô hình, thiếu API key, đang compaction), .catch ghi một phản hồi lỗi; lỗi xảy ra sau khi đã chấp nhận thì chỉ xuất hiện trong luồng sự kiện. Hành vi “một phản hồi có thẩm quyền sau preflight” có từ 0.67.3 (CHANGELOG.md:2740), còn disposition có từ 0.99.0 (CHANGELOG.md:450). abort thì ngược lại: nó chờ session rảnh hẳn rồi mới trả lời.
Mỗi dòng stdin đi qua handleInputLine():
const handleInputLine = async (line: string) => { let parsed: unknown; try { parsed = JSON.parse(line); } catch (parseError: unknown) { output( error( undefined, "parse", `Failed to parse command: ${parseError instanceof Error ? parseError.message : String(parseError)}`, ), ); await waitForRawStdoutBackpressure(); return; } // Handle extension UI responses if ( typeof parsed === "object" && parsed !== null && "type" in parsed && parsed.type === "extension_ui_response" ) { const response = parsed as RpcExtensionUIResponse; const pending = pendingExtensionRequests.get(response.id); if (pending) { pendingExtensionRequests.delete(response.id); pending.resolve(response); } return; } const command = parsed as RpcCommand; try { const response = await handleCommand(command); if (response) { output(response); await waitForRawStdoutBackpressure(); } await checkShutdownRequested(); } catch (commandError: unknown) { output( error( command.id, command.type, commandError instanceof Error ? commandError.message : String(commandError), ), ); await waitForRawStdoutBackpressure(); }};Ba nhánh. JSON hỏng nhận một phản hồi lỗi với command: "parse" và không có id. Một extension_ui_response được chuyển cho hộp thoại đang chờ có cùng id và không sinh phản hồi nào. Mọi thứ còn lại là lệnh. Điểm quan trọng nằm ở chỗ gắn bộ đọc: mỗi dòng được xử lý bằng void handleInputLine(line), không chờ dòng trước xong (rpc-mode.ts:807–815). Nhờ vậy abort hay steer được xử lý ngay cả khi một lệnh chậm như compact đang chạy, và cũng vì vậy client phải ghép phản hồi theo id, không theo thứ tự (rpc.md:44). Khi stdin đóng, shutdown() dispose runtime, xả stdout và thoát; hàm runRpcMode() trả về một promise không bao giờ resolve để giữ tiến trình sống (rpc-mode.ts:726–743, :817–818).
Extension UI đi qua RPC#
Vấn đề. Một extension như permission-gate gọi await ctx.ui.select("Allow?", ["Yes", "No"]) ngay giữa hook tool_call, tức giữa vòng lặp agent. Trong TUI, đó là một hộp thoại. Trong RPC không có màn hình nào; người duy nhất trả lời được là client ở đầu kia của pipe. Giải pháp là một giao thức con: Pi ghi một extension_ui_request có id ngẫu nhiên, giữ promise của hộp thoại trong một bảng chờ, và resolve nó khi client gửi extension_ui_response cùng id.
export type RpcExtensionUIRequest = | { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number } | { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number } | { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string; timeout?: number; } | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string } | { type: "extension_ui_request"; id: string; method: "setTitle"; title: string } | { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string };// ============================================================================// Extension UI Commands (stdin)// ============================================================================/** Response to an extension UI request */export type RpcExtensionUIResponse = | { type: "extension_ui_response"; id: string; value: string } | { type: "extension_ui_response"; id: string; confirmed: boolean } | { type: "extension_ui_response"; id: string; cancelled: true };function createDialogPromise<T>( opts: ExtensionUIDialogOptions | undefined, defaultValue: T, request: Record<string, unknown>, parseResponse: (response: RpcExtensionUIResponse) => T,): Promise<T> { if (opts?.signal?.aborted) return Promise.resolve(defaultValue); const id = crypto.randomUUID(); return new Promise((resolve, reject) => { let timeoutId: ReturnType<typeof setTimeout> | undefined; const cleanup = () => { if (timeoutId) clearTimeout(timeoutId); opts?.signal?.removeEventListener("abort", onAbort); pendingExtensionRequests.delete(id); }; const onAbort = () => { cleanup(); resolve(defaultValue); }; opts?.signal?.addEventListener("abort", onAbort, { once: true }); if (opts?.timeout) { timeoutId = setTimeout(() => { cleanup(); resolve(defaultValue); }, opts.timeout); } pendingExtensionRequests.set(id, { resolve: (response: RpcExtensionUIResponse) => { cleanup(); resolve(parseResponse(response)); }, reject, }); output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest); });}Hộp thoại có ba cách kết thúc: client trả lời, timeout hết hạn, hoặc AbortSignal của extension bị huỷ. Hai cách sau resolve bằng giá trị mặc định, nên client không cần tự đếm giờ (rpc-extension-ui.md:10). Các phương thức của ExtensionUIContext chia làm hai loại:
const createExtensionUIContext = (): ExtensionUIContext => ({ select: (title, options, opts) => createDialogPromise(opts, undefined, { method: "select", title, options, timeout: opts?.timeout }, (r) => "cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined, ), confirm: (title, message, opts) => createDialogPromise(opts, false, { method: "confirm", title, message, timeout: opts?.timeout }, (r) => "cancelled" in r && r.cancelled ? false : "confirmed" in r ? r.confirmed : false, ), input: (title, placeholder, opts) => createDialogPromise(opts, undefined, { method: "input", title, placeholder, timeout: opts?.timeout }, (r) => "cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined, ), notify(message: string, type?: "info" | "warning" | "error"): void { // Fire and forget - no response needed output({ type: "extension_ui_request", id: crypto.randomUUID(), method: "notify", message, notifyType: type, } as RpcExtensionUIRequest); },- Hộp thoại (
select,confirm,input,editor): chờ phản hồi. Riêngeditor()tự dựng promise mà không quacreateDialogPromise, nên không có timeout hay abort (rpc-mode.ts:254–271); kiểu yêu cầu của nó cũng không có trườngtimeout. - Ghi rồi thôi (
notify,setStatus,setWidget,setTitle,setEditorText): ghi một bản ghi và không chờ gì.setWidgetchỉ nhận mảng chuỗi; widget dạng component bị bỏ qua (rpc-mode.ts:195–208). - Không hỗ trợ:
custom()trảundefined,getEditorText()trả""vì một hàm đồng bộ không thể chờ RPC, footer, header, chỉ báo “working”, đổi theme đều là no-op (rpc-extension-ui.md:12–23). Extension cần UI thật của terminal nên kiểm tractx.mode === "tui".
RpcClient: phía client#
Với client TypeScript, gói xuất sẵn RpcClient: nó khởi động node <cliPath> --mode rpc với các cờ tuỳ chọn (rpc-client.ts:81–98), rồi bọc mỗi lệnh thành một phương thức có kiểu. Mọi phương thức đi qua send():
const id = `req_${++this.requestId}`;const fullCommand = { ...command, id } as RpcCommand;return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.stderr}`)); }, 30000); this.pendingRequests.set(id, { resolve: (response) => { clearTimeout(timeout); resolve(response); }, reject: (error) => { clearTimeout(timeout); reject(error); }, }); try { stdin.write(serializeJsonLine(fullCommand)); } catch (error: unknown) { const writeError = error instanceof Error ? error : new Error(String(error)); const pending = this.pendingRequests.get(id); this.pendingRequests.delete(id); pending?.reject(writeError); }});private handleLine(line: string): void { try { const data = JSON.parse(line); // Check if it's a response to a pending request if (data.type === "response" && data.id && this.pendingRequests.has(data.id)) { const pending = this.pendingRequests.get(data.id)!; this.pendingRequests.delete(data.id); pending.resolve(data as RpcResponse); return; } // Otherwise it's an event. Iterate a snapshot so listeners that unsubscribe during dispatch // do not cause later listeners to miss this event. for (const listener of [...this.eventListeners]) { listener(data as JsonAgentSessionEvent); } } catch { // Ignore non-JSON lines }}Bản ghi nào không phải phản hồi của một yêu cầu đang chờ đều được coi là sự kiện, kể cả extension_ui_request. Nhưng RpcClient ở v1.4.0 không có phương thức nào để ghi extension_ui_response: send() là private và luôn gắn id rồi chờ một phản hồi. Vì vậy ví dụ chính thức về extension UI không dùng RpcClient mà tự spawn tiến trình, tự đọc stdout và tự ghi phản hồi hộp thoại vào stdin (examples/rpc-extension-ui.ts:259–263, :540). Hai tiện ích cuối của RpcClient giải quyết một cuộc đua (race) dễ mắc:
waitForIdle(timeout = 60000): Promise<void> { return new Promise((resolve, reject) => { const timer = setTimeout(() => { unsubscribe(); reject(new Error(`Timeout waiting for agent to become idle. Stderr: ${this.stderr}`)); }, timeout); const unsubscribe = this.onEvent((event) => { if (event.type === "agent_settled") { clearTimeout(timer); unsubscribe(); resolve(); } }); });}async promptAndWait(message: string, images?: ImageContent[], timeout = 60000): Promise<JsonAgentSessionEvent[]> { const eventsPromise = this.collectEvents(timeout); await this.prompt(message, images); return eventsPromise;}Một prompt ngắn có thể chạy xong, và agent_settled có thể tới, trước khi lời gọi prompt() kịp trả về. Nếu client chỉ đăng ký listener sau đó, nó chờ mãi. promptAndWait() tạo promise thu sự kiện trước rồi mới gửi lệnh (rpc.md:71). Ví dụ chính thức dùng đúng mẫu đó:
const client = new RpcClient({ cliPath: join(exampleDirectory, "../dist/cli.js"), args: ["--no-session"],});const unsubscribe = client.onEvent((event) => { if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { process.stdout.write(event.assistantMessageEvent.delta); } else if (event.type === "tool_execution_start") { process.stderr.write(`\n[tool: ${event.toolName}]\n`); }});try { await client.start(); await client.promptAndWait(prompt); process.stdout.write("\n");} finally { unsubscribe(); await client.stop();}SDK: nhúng Pi trong tiến trình#
SDK không phải một chế độ CLI. Nó là chính gói @earendil-works/pi-coding-agent được import như thư viện, cho chương trình Node.js hay Bun truy cập thẳng agent, session, tool và mô hình (cli-integration.md:7). Hàm trung tâm là createAgentSession(), đúng hàm mà factory của CLI gọi qua createAgentSessionFromServices(). Ví dụ tối giản trong repo:
import { createAgentSession } from "@earendil-works/pi-coding-agent";const { session } = await createAgentSession();try { session.subscribe((event) => { if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { process.stdout.write(event.assistantMessageEvent.delta); } }); await session.prompt("What files are in the current directory?"); session.state.messages.forEach((msg) => { console.log(msg); }); console.log();} finally { session.dispose();}Không truyền gì, hàm dùng thư mục hiện tại, settings đã lưu, credential đã cấu hình và resource được phát hiện. Mỗi ranh giới đều thay được qua options:
export interface CreateAgentSessionOptions { /** Working directory for project-local discovery. Default: process.cwd() */ cwd?: string; /** Global config directory. Default: ~/.pi/agent */ agentDir?: string; /** Canonical model/auth runtime. Defaults to a runtime using agentDir/auth.json and models.json. */ modelRuntime?: ModelRuntime; /** Model to use. Default: from settings, else first available */ model?: Model<any>; /** Thinking level. Default: from settings, else 'medium' (clamped to model capabilities) */ thinkingLevel?: ThinkingLevel; /** Models available for cycling (Ctrl+P in interactive mode) */ scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>; /** Custom tools to register (in addition to built-in tools). */ customTools?: ToolDefinition[]; /** Resource loader. When omitted, DefaultResourceLoader is used. */ resourceLoader?: ResourceLoader; /** Session manager. Default: SessionManager.create(cwd) */ sessionManager?: SessionManager; /** Settings manager. Default: SettingsManager.create(cwd, agentDir) */ settingsManager?: SettingsManager; /** Session start event metadata for extension runtime startup. */ sessionStartEvent?: SessionStartEvent;}Bên trong, hàm dựng ModelRuntime, SettingsManager, SessionManager, DefaultResourceLoader, rồi một Agent và một AgentSession bọc nó (sdk.ts:191–207, :453–470); cách Agent được cấu hình có ở chương pi-agent-core, còn AgentSession ở chương AgentSession. Điều quan trọng với người nhúng là những gì SDK không làm so với main():
- Không chiếm stdout và không chọn mode: host tự quyết cách hiển thị.
- Không nạp các extension dựng sẵn của CLI (
codemode,tool_search, MCP); host phải tự thêm vàoextensionFactoriescủaDefaultResourceLoader(sdk.md:116). - Không gọi
bindExtensions(), nên extension chưa nhậnsession_startvà chạy với mode"print", không UI, cho tới khi host tự gắn. MCP chỉ kết nối server ởsession_start, nên quên bước này nghĩa là không có tool MCP.
Host nào cần /new, resume hay fork thì dựng AgentSessionRuntime với một factory, đúng như main(), và gắn lại listener sau mỗi lần thay session:
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => { const services = await createAgentSessionServices({ cwd }); return { ...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent, })), services, diagnostics: services.diagnostics, };};const runtime = await createAgentSessionRuntime(createRuntime, { cwd: process.cwd(), agentDir: getAgentDir(), sessionManager: SessionManager.create(process.cwd()),});let unsubscribe: (() => void) | undefined;async function bindSession() { unsubscribe?.(); const session = runtime.session; await session.bindExtensions({}); unsubscribe = session.subscribe((event) => { if (event.type === "queue_update") { console.log("Queued:", event.steering.length + event.followUp.length); } }); return session;}Sau khi runtime thay session, mọi subscription cũ thuộc về một AgentSession đã dispose (sdk.md:60), đúng vấn đề mà setRebindSession() giải quyết cho các mode CLI. Gói còn xuất chính các mode (InteractiveMode, runPrintMode, runRpcMode, index.ts:417–434) và cả main() với MainOptions.extensionFactories. pi nạp extension dựng sẵn của chính nó qua đúng đường đó (main.ts:570–578), nên một host có thể phát hành CLI riêng kèm extension inline mà không phải sửa Pi.
Chọn SDK hay RPC? Tài liệu tóm tắt: SDK chạy trong cùng tiến trình và có toàn bộ API TypeScript; RPC chạy trong tiến trình con, điều khiển bằng JSONL, hợp với ngôn ngữ khác, IDE, hay khi muốn cô lập tiến trình (rpc.md:7–10).
Interactive mode ở mức cấu trúc#
interactive-mode.ts dài hơn 7.000 dòng, nhưng phần lớn là giao diện: bộ chọn, hộp thoại, phím tắt, cách vẽ từng loại message (chương pi-tui). Ở mức cấu trúc, nó là một mode như mọi mode khác: nhận runtime, gắn extension, đăng ký sự kiện, và đẩy đầu vào của người dùng vào session.prompt().
- constructorLưu runtime, đăng ký
setRebindSessionvàsetBeforeSessionInvalidate, dựng TUI và cây component (:610). - init()Gắn component vào TUI,
ui.start()trước extension đểsession_startdùng được hộp thoại, cài phím, rồirebindCurrentSession()(:999–1105). - bind
bindExtensions({ uiContext, mode: "tui", ... })vàsubscribeToAgent()(:2092–2118). - run()Kiểm tra phiên bản, cập nhật gói, danh mục mô hình chạy nền; gửi prompt khởi đầu; rồi vòng lặp chính.
// Process initial messagesif (initialMessage) { try { await this.session.prompt(initialMessage, { images: initialImages }); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); }}if (initialMessages) { for (const message of initialMessages) { try { await this.session.prompt(message); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); } }}// Main interactive loopwhile (true) { const userInput = await this.getUserInput(); try { await this.session.prompt(userInput); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); }}Trong lúc một lần chạy diễn ra, vòng lặp chính đang đứng ở await this.session.prompt(...). Nếu người dùng nhấn Enter lúc đó, bộ xử lý submit của editor không đi qua vòng lặp này mà gửi thẳng một prompt có streamingBehavior: "steer"; tổ hợp phím follow-up (mặc định Alt+Enter) gửi "followUp" (:4460–4468):
// If streaming, use prompt() with steer behavior // This handles extension commands (execute immediately), prompt template expansion, and queueing if (this.session.isStreaming) { this.editor.addToHistory?.(text); this.editor.setText(""); await this.session.prompt(text, { streamingBehavior: "steer" }); this.updatePendingMessagesDisplay(); this.ui.requestRender(); return; } // Normal message submission // First, move any pending bash components to chat this.flushPendingBashComponents(); if (this.onInputCallback) { this.onInputCallback(text); } else { this.pendingUserInputs.push(text); } this.editor.addToHistory?.(text); };private subscribeToAgent(): void { this.unsubscribe = this.session.subscribe(async (event) => { await this.handleEvent(event); });}Trước nhánh đó, bộ xử lý submit đã tự xử lý các lệnh dựng sẵn như /settings, /model, /resume, /quit và lệnh bash !/!! (:3156–3319). Mọi thứ còn lại, kể cả lệnh của extension, đi vào session.prompt(), cùng hàm mà RPC và print gọi. Chiều ngược lại, handleEvent() biến mỗi AgentSessionEvent thành một thay đổi trên cây component rồi xin vẽ lại:
case "message_start": if (event.message.role === "custom") { this.addMessageToChat(event.message); this.ui.requestRender(); } else if (event.message.role === "user") { this.addMessageToChat(event.message); this.updatePendingMessagesDisplay(); this.ui.requestRender(); } else if (event.message.role === "assistant") { this.streamingComponent = new AssistantMessageComponent( undefined, this.hideThinkingBlock, this.getMarkdownThemeWithSettings(), this.hiddenThinkingLabel, this.outputPad, this.getMarkdownTransformers(), ); this.streamingMessage = event.message; this.chatContainer.addChild(this.streamingComponent); this.streamingComponent.updateContent(this.streamingMessage, true); this.ui.requestRender(); } break;| Sự kiện | Interactive mode làm gì |
|---|---|
message_start (user, custom, assistant) | Thêm message vào khung chat; với assistant, tạo component đang stream |
message_update | Cập nhật component; mỗi khối toolCall mới sinh một ToolExecutionComponent ngay khi đối số còn đang stream (:3486–3511) |
message_end | Chốt message; nếu lỗi hoặc bị huỷ, đánh dấu lỗi cho các tool đang chờ |
tool_execution_* | Cập nhật đúng component theo toolCallId; lời gọi lồng nhau (parentToolCallId) hiện bên trong dòng của tool cha (:3563–3608) |
queue_update | Vẽ lại danh sách tin steer/follow-up đang chờ |
compaction_*, auto_retry_* | Chỉ báo trạng thái |
agent_settled | Kiểm tra yêu cầu tắt từ extension |
Trace: một phiên RPC có hộp thoại của extension#
Kịch bản: một client khởi động pi --mode rpc --no-session -e ./permission-gate.ts, gửi một prompt khiến mô hình gọi bash với rm -rf build. Extension permission-gate chặn lại hỏi ý kiến; client đồng ý. Mũi tên → là dòng client ghi vào stdin, ← là dòng Pi ghi ra stdout.
Ví dụ minh hoạ: trao đổi JSONL (các trường dài được rút gọn bằng …)
→ {"id":"req-1","type":"prompt","message":"Xoá thư mục build rồi báo lại"}
← {"id":"req-1","type":"response","command":"prompt","success":true,"data":{"disposition":"started"}}
← {"type":"agent_start"}
← {"type":"turn_start"}
← {"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"Xoá thư mục build rồi báo lại"}],…}}
← {"type":"message_end","message":{"role":"user",…}}
← {"type":"message_start","message":{"role":"assistant","content":[],"stopReason":"pending",…}}
← {"type":"message_update","usage":{…},"assistantMessageEvent":{"type":"toolcall_start","contentIndex":0,"id":"call_1","toolName":"bash"}}
← {"type":"message_update","usage":{…},"assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"{\"command\":\"rm -rf build\"}"}}
← {"type":"message_update","usage":{…},"assistantMessageEvent":{"type":"toolcall_end","contentIndex":0,"toolCall":{…}}}
← {"type":"message_end","message":{"role":"assistant","stopReason":"toolUse",…}}
← {"type":"tool_execution_start","toolCallId":"call_1","toolName":"bash","args":{"command":"rm -rf build"}}
← {"type":"extension_ui_request","id":"5f0c…","method":"select","title":"… rm -rf build … Allow?","options":["Yes","No"]}
vòng lặp agent đứng chờ trong beforeToolCall; các lệnh khác vẫn được xử lý
→ {"type":"extension_ui_response","id":"5f0c…","value":"Yes"}
← {"type":"tool_execution_end","toolCallId":"call_1","toolName":"bash","result":{"content":[{"type":"text","text":"(no output)"}],…},"isError":false,"durationMs":14}
← {"type":"message_start","message":{"role":"toolResult","toolCallId":"call_1",…}}
← {"type":"message_end","message":{"role":"toolResult",…}}
← {"type":"turn_end","message":{…},"toolResults":[{…}]}
← {"type":"turn_start"}
← {"type":"message_start","message":{"role":"assistant",…}}
← {"type":"message_update","usage":{…},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Đã xoá thư mục build."}}
← {"type":"message_end","message":{"role":"assistant","stopReason":"stop",…}}
← {"type":"turn_end","message":{…},"toolResults":[]}
← {"type":"agent_end","messages":[…],"willRetry":false}
← {"type":"agent_settled","aborted":false}
→ {"id":"req-2","type":"get_state"}
← {"id":"req-2","type":"response","command":"get_state","success":true,"data":{"isStreaming":false,…}}
req-1 tới trước agent_start; agent_settled mới là tín hiệu xong.Đi lại từng chặng, kèm chỗ code chịu trách nhiệm:
handleInputLine()parse dòng đầu và gọihandleCommand(). Nhánhpromptkhởi độngsession.prompt()mà không await.AgentSessionkiểm tra đã có mô hình và API key, chuẩn bị lần chạy, rồi gọipreflightResult("started")ngay trướcagent.prompt()(agent-session.ts:1873–1899). Vì mọi bản ghi đi qua cùng một hàng đợiwriteRawStdout, phản hồi luôn đứng trướcagent_start.- Các sự kiện từ
agent_starttớimessage_endcủa assistant là chuỗi sự kiện của vòng lặp agent (trace ở chương pi-agent-core), đi quatoJsonEvent()nênmessage_updatechỉ còn delta, vàtoolcall_startmangid,toolName. tool_execution_startđược phát trước khi chuẩn bị lời gọi; ngay sau đó vòng lặp gọibeforeToolCall, tức hooktool_callcủa extension. permission-gate thấyhasUIlà true và gọictx.ui.select(), mà trong RPC làcreateDialogPromise(): mộtextension_ui_requestvới UUID mới được ghi ra, và promise nằm trong bảng chờ. Không cótimeoutvì extension không truyền.- Client ghi
extension_ui_response. Vì mỗi dòng stdin được xử lý độc lập, dòng này không phải chờreq-1.handleInputLine()tìm thấyidtrong bảng chờ và resolve promise với"Yes". Hook trả vềundefined, bash chạy, kết quả đi quaafterToolCallrồi ra thànhtool_execution_endvà messagetoolResult. - Lượt 2 trả lời bằng văn bản, không gọi tool; vòng lặp kết thúc với
agent_end.AgentSessionkiểm tra xem có cần thử lại, compaction hay tin chờ không; không có gì nên nó phátagent_settled(agent-session.ts:1899–1917).
Hai biến thể đáng thử trong đầu. Nếu client trả "No", permission-gate trả { block: true, reason: "Blocked by user" }; vòng lặp tạo một kết quả lỗi mang đúng lý do đó, nên tool_execution_end có isError: true và mô hình đọc được lý do ở lượt 2. Nếu chạy cùng kịch bản bằng pi -p, hasUI là false và extension chặn ngay với lý do “Dangerous command blocked (no UI for confirmation)”, không có bản ghi UI nào. Còn nếu trong lúc bash chạy client gửi {"id":"req-3","type":"steer","message":"..."}, Pi trả {"disposition":"queued"} và phát queue_update; khi tin đó được chèn sau batch tool, AgentSession gỡ nó khỏi danh sách chờ và phát queue_update mới trước khi chuyển tiếp message_start của tin nhắn user đó (agent-session.ts:1120–1135).
Quyết định thiết kế#
Một runtime, nhiều lớp vỏ
“All four modes use the same agent, sessions, resources, and tools. The mode determines how input enters Pi, how output is exposed, and whether the process remains available for more commands” (cli-integration.md:5). Lợi ích thấy ngay trong code: compaction, retry, extension, session tree được viết một lần trong AgentSession và chạy ở mọi chế độ; một client RPC có cùng hành vi steer/follow-up như người gõ trong TUI. Cái giá là mọi tính năng UI phải có đường suy biến khi không có màn hình, do đó có noOpUIContext, hasUI và ctx.mode.
Chọn chế độ theo TTY
Chọn print khi stdin hoặc stdout không phải terminal làm Pi khớp với thói quen Unix: pipe vào, pipe ra, không cần cờ. Đánh đổi là một chút bất ngờ cho người chuyển hướng output mà vẫn tưởng sẽ thấy TUI, và nhu cầu phân biệt rõ --print (chạy một lần) với --mode (định dạng đầu ra) (cli.md:41).
Bảo vệ stdout thay vì tin vào code khác
Extension là module TypeScript chạy ngay trong tiến trình Pi (how-pi-works.md:43), nên không thể giả định không ai gọi console.log. Thay vì đòi mọi extension tuân thủ, Pi chuyển hướng process.stdout.write sang stderr và giữ cho mình một lối ghi riêng. Thiết kế này đổi một lớp lỗi khó gỡ (luồng JSONL hỏng giữa chừng) lấy một quy ước đơn giản: stdout chỉ có bản ghi giao thức, stderr chứa mọi thứ khác.
Giao thức tuyến tính, có áp lực ngược
Ba lựa chọn cùng hướng: JSONL chỉ tách ở LF (0.57.0), message_update chỉ mang delta để kích thước tuyến tính (0.84.0), và áp lực ngược từ stdout tới tận vòng lặp agent nhờ listener được await. Kết quả là một client chậm làm Pi chậm lại thay vì làm Pi phình bộ nhớ, và một câu trả lời dài không làm luồng sự kiện phình theo bình phương.
Tách “được nhận”, “xong một lần chạy” và “hết việc”
Một client cần ba tín hiệu khác nhau, và giao thức tách chúng ra. Phản hồi của prompt nói prompt đã được nhận và đi đâu (disposition). agent_end nói một lần chạy cấp thấp đã xong. agent_settled nói Pi không còn tự làm gì nữa, vì một lần chạy cấp session có thể gồm nhiều lần chạy cấp thấp: thử lại sau lỗi provider, compaction khi tràn rồi chạy lại, follow-up (agent-session.ts:1899–1909). agent_settled xuất hiện ở 0.80.4 (CHANGELOG.md:1484) và có thêm cờ aborted ở 1.1.0 (CHANGELOG.md:186).
UI của extension là một giao thức con, suy biến có kiểm soát
Thay vì cấm extension dùng hộp thoại ngoài TUI, RPC chuyển hộp thoại thành cặp yêu cầu và phản hồi có id, có timeout phía Pi, và biến những gì không thể làm qua dây (component tuỳ biến, footer, đọc editor đồng bộ) thành no-op thay vì lỗi. Nhờ vậy cùng một extension chạy được trong TUI, trong IDE dùng RPC và trong script, miễn nó xét hasUI và mode.
Factory theo cwd thay vì một session cố định
Vì services phụ thuộc cwd, Pi không dựng chúng một lần lúc khởi động mà giữ một factory để dựng lại khi session đổi thư mục. Chú thích của AgentSessionRuntime nói rõ thứ tự: tháo runtime cũ trước, rồi dựng và áp dụng runtime mới; nếu dựng thất bại, lỗi được ném lên cho caller (agent-session-runtime.ts:67–73).
Cạm bẫy và trường hợp biên#
--mode texttrong terminal vẫn mở TUI. Muốn chạy một lần, dùng-p.- Pipe vào stdin âm thầm chuyển sang print.
echo "..." | pikhông mở TUI (main.ts:898–900). - RPC từ chối
@file. Gửi ảnh qua trườngimagescủa lệnhprompt. - JSON mode không đặt mã thoát theo kết quả mô hình. Một câu trả lời lỗi vẫn cho mã 0; phải xem
stopReasontrongmessage_endcủa assistant. - Đăng ký listener trước khi gửi prompt, nếu không một lần chạy ngắn có thể xong trước khi bạn kịp nghe.
- Ghép phản hồi theo
id. Lệnh được xử lý đồng thời, nên thứ tự phản hồi không theo thứ tự lệnh; lỗi parse không cóid. - Ngừng đọc stdout là làm Pi đứng. Áp lực ngược đi tới tận vòng lặp agent.
- Đừng dùng
readlinecho luồng của Pi; nó tách sai ở U+2028/U+2029. - Hộp thoại không có UI trả về ngay giá trị mặc định. Trong print, JSON và SDK chưa gắn UI,
confirm()trảfalsevàselect()trảundefinedngay lập tức. Extension không xéthasUIcó thể đi nhầm nhánh mà không ai thấy. - Listener của
session.subscribe()không phải rào chắn. Chúng được gọi mà không await; muốn chặn vòng lặp cho tới khi việc async xong (như áp lực ngược của các mode), phải đăng ký trênsession.agent, và khi đó chỉ nhậnAgentEvent. - SDK sau khi thay session: listener cũ thuộc về session đã dispose; phải đăng ký lại và gọi lại
bindExtensions(). - Chú thích JSDoc của
createAgentSession()còn ví dụ cũcreateAgentSession({ continueSession: true })(sdk.ts:171–174), nhưngCreateAgentSessionOptionsở v1.4.0 không có trường đó. Cách đúng là truyềnsessionManager: SessionManager.continueRecent(cwd), nhưmain()làm cho-c(main.ts:432–434). pi -c -pkhông kèm prompt không gửi gì cho mô hình; theo code, print mode vẫn đọc message cuối của transcript đã khôi phục, nên nếu message cuối là của assistant, nó in lại câu trả lời cuối của session gần nhất (print-mode.ts:139–143).RpcClientkhông trả lời được hộp thoại của extension. Nếu extension của bạn gọictx.ui.select()hayconfirm()không kèmtimeouthaysignal, một client chỉ dùngRpcClientsẽ để vòng lặp agent chờ mãi; hãy tự quản lý tiến trình nhưexamples/rpc-extension-ui.ts, hoặc đặttimeoutcho hộp thoại.RpcClientmặc định tìmdist/cli.jstương đối với thư mục hiện tại của client (rpc-client.ts:81); truyềncliPathrõ ràng như ví dụ chính thức.
Đọc code ở đâu#
| Thứ tự | File | Đọc để hiểu |
|---|---|---|
| 1 | how-pi-works.md, cli-integration.md | Các chế độ là gì và chọn chế độ nào. |
| 2 | src/cli.ts, src/main.ts:574 | Đường khởi động: lệnh con, resolveAppMode(), session, factory, rẽ nhánh. |
| 3 | src/cli/args.ts, docs/cli.md | Mọi tuỳ chọn dòng lệnh và cách chúng được phân tích. |
| 4 | agent-session-runtime.ts, agent-session-services.ts | Services theo cwd, factory, thay session và rebind. |
| 5 | src/core/sdk.ts:191, docs/sdk.md | createAgentSession(); sau đó sang chương AgentSession. |
| 6 | print-mode.ts, json-event.ts, output-guard.ts | Chế độ một lần, định dạng trên dây, bảo vệ stdout; tham chiếu sự kiện ở docs/json.md. |
| 7 | rpc-types.ts, jsonl.ts | Data model và khung bản ghi của giao thức. |
| 8 | rpc-mode.ts, docs/rpc.md, rpc-commands.md, rpc-extension-ui.md | Server RPC, từng lệnh, giao thức con của extension UI. |
| 9 | rpc-client.ts, examples/rpc-client.ts, examples/rpc-extension-ui.ts | Phía client, kể cả cách trả lời hộp thoại. |
| 10 | interactive-mode.ts:945 | init(), run() (1148), submit (3156), handleEvent() (3363); phần vẽ ở chương pi-tui. |
| 11 | examples/sdk/, permission-gate.ts | Mười bốn ví dụ SDK được typecheck cùng repo; một extension mẫu xử lý cả có và không có UI. |
Tóm tắt#
main()dựng mộtAgentSessionRuntimeduy nhất qua một factory theo cwd, rồi giao nó cho một trong ba lớp vỏ:InteractiveMode,runPrintMode()(text và JSON),runRpcMode(). SDK gọi cùng các hàm đó trong tiến trình của host.- Chế độ được chọn theo thứ tự:
--mode rpc,--mode json, rồi print nếu có-phoặc stdin/stdout không phải TTY, cuối cùng là interactive. - Mỗi mode khác nhau ở bốn điểm: đầu vào, cách xuất sự kiện,
ExtensionUIContextđưa cho extension, và vòng đời tiến trình. - Các chế độ không tương tác chuyển
console.logsang stderr và ghi giao thức qua một hàng đợi riêng, có áp lực ngược tới vòng lặp agent. - JSON và RPC dùng JSONL chỉ tách ở LF, và
message_updatetrên dây chỉ mang delta. - RPC: lệnh có
idtuỳ chọn và được xử lý đồng thời;prompttrả lời một lần sau preflight kèmdisposition;agent_settledlà tín hiệu hết việc; hộp thoại của extension là cặpextension_ui_request/extension_ui_response. - Khi session bị thay (
/new, resume, fork, import), runtime tháo session cũ, dựng lại services và gọi callback rebind của mode.
13pi-tui: giao diện terminal vẽ theo vi sai#
Vai trò của pi-tui#
Một coding agent chạy trong terminal phải vẽ cùng lúc nhiều thứ thay đổi liên tục: câu trả lời đang stream từng token, các hàng tool với trạng thái đang chạy, ô soạn thảo nơi người dùng vẫn gõ được, footer với số token và phần trăm cửa sổ ngữ cảnh (context window). Terminal không có DOM. Thứ duy nhất chương trình có là một luồng byte ghi ra stdout, gồm chữ xen với các chuỗi điều khiển (escape sequence) để di chuyển con trỏ, xoá dòng, đổi màu. Nếu mỗi token mới lại xoá màn hình rồi vẽ lại tất cả, màn hình nhấp nháy, CPU bận vô ích, và scrollback (phần lịch sử mà terminal giữ phía trên màn hình) bị phá. pi-tui giải quyết đúng việc này: ứng dụng mô tả giao diện bằng một cây component, mỗi component chỉ biết trả về các dòng văn bản cho một độ rộng; pi-tui so khung hình mới với khung trước rồi chỉ ghi phần khác.
Gói là @earendil-works/pi-tui, thư mục packages/tui, khoảng 19.600 dòng TypeScript trong src/. README mô tả nó là “Minimal terminal UI framework with differential rendering and synchronized output for flicker-free interactive CLI applications” (README.md:3). Lúc chạy, gói chỉ phụ thuộc ba thư viện: get-east-asian-width để đo độ rộng ký tự Đông Á, marked và marked-cjk-friendly để phân tích Markdown (package.json:53–57). Phần còn lại, từ phân tích phím, độ rộng grapheme, bố cục, cuộn, chọn văn bản tới ảnh inline, đều tự viết.
Ai dùng gói này:
- Chế độ interactive của coding agent (
packages/coding-agent/src/modes/interactive/) là người dùng chính: nó dựng toàn bộ giao diện chat trên pi-tui (mục Interactive mode). Các chế độ print, JSON, RPC và SDK không vẽ TUI; xem chương Các chế độ chạy. - Lệnh
pi configdựng riêng mộtTuiMainScreennhỏ cho bộ chọn cấu hình (config-selector.ts:25). - Extension vẽ hộp thoại, widget, overlay qua
ctx.uibằng chính các component của pi-tui. Bề mặt API đó thuộc chương Extensions; tài liệu dặn rõ “Do not create a second terminal renderer inside an extension” (docs/tui.md:18).
app.*; nối sự kiện của session vào component.TuiMainScreen | TuiAltScreenContainer, Text, Markdown, Editor, Image, SelectList, VStack, ScrollView... Mỗi cái trả về string[] cho một độ rộng.ProcessTerminalMô hình tư duy: component, frame và vi sai#
Mọi thứ trong chương xoay quanh một vòng: có sự kiện, component đổi state và xin vẽ lại; TUI gộp các lời xin đó thành một khung hình (frame), dựng khung bằng cách gọi render(width) trên cây, rồi so với khung trước để quyết định ghi byte nào.
sự kiện: token mới, phím bấm, resize, nhịp spinner, màu terminal trả về
│ component đổi state, gọi tui.requestRender()
▼
requestRender() gộp lại: tối đa một khung mỗi 16 ms; phím bấm thì vẽ ngay ở nextTick
▼
doRender()
1. lines = root.render(width) mỗi phần tử một dòng, rộng không quá width
2. resolveFakeCursors(lines) con trỏ giả thành video đảo (SGR 7 / 27)
3. compositeOverlays(lines) chồng overlay lên các dòng nền
4. extractCursorPosition(lines) tìm và gỡ CURSOR_MARKER, nhớ hàng/cột
5. applyLineResets(lines) nối \x1b[0m\x1b]8;;\x07 vào cuối mỗi dòng
6. so với khung trước
├─ TuiMainScreen (regular): từ dòng đầu tiên khác, ghi đè tới hết;
│ dòng dư ở đáy đẩy dòng cũ lên scrollback
└─ TuiAltScreen (fullscreen): so từng hàng của viewport, hàng khác thì ghi
7. bọc trong \x1b[?2026h … \x1b[?2026l, ghi ra stdout, lưu làm khung trước
TuiAltScreen có thêm tô kết quả tìm kiếm, vùng chọn và thông báo nổi).Từ vựng dùng trong phần còn lại:
- Component
- Đối tượng có
render(width): string[]. Mỗi chuỗi là một dòng terminal, có thể chứa mã ANSI, và độ rộng hiển thị không được vượtwidth. - Retained mode (giữ cây)
- Ứng dụng giữ một cây đối tượng component sống lâu, đổi state của chúng, còn TUI gọi lại
render()trên cả cây mỗi khung. Ngược với immediate mode, nơi ứng dụng tự vẽ lại từ đầu mỗi khung. Component tự cache kết quả nên phần lớn lời gọirender()chỉ trả về mảng cũ. - Khung hình (frame)
- Mảng dòng TUI có được sau bước 5. Khung trước được giữ trong
previousLines(regular) hoặcpreviousScreen(fullscreen). - Viewport, scrollback
- Viewport là phần đang hiện trên màn hình, cao bằng số hàng của terminal. Scrollback là lịch sử phía trên viewport mà terminal tự giữ; chương trình không sửa lại được những gì đã trôi vào đó.
- Main screen, alternate screen
- Terminal có hai bộ đệm. Main screen có scrollback. Alternate screen (bật bằng
\x1b[?1049h) là một tấm cố định đúng bằng màn hình, không có scrollback, và khi tắt thì main screen hiện lại nguyên vẹn; vim và less dùng nó. - Synchronized output
- Cặp
CSI ?2026h/CSI ?2026l(\x1b[?2026h…\x1b[?2026l): terminal hỗ trợ sẽ gom mọi thay đổi ở giữa rồi hiện một lần, nên người dùng không thấy trạng thái vẽ dở. Terminal không hỗ trợ thì bỏ qua cặp mã này. - Vi sai (differential rendering)
- Chỉ ghi những dòng (hoặc hàng) khác với khung trước, thay vì vẽ lại toàn bộ.
Các chuỗi điều khiển xuất hiện nhiều nhất trong code:
| Chuỗi | Nghĩa | Ai dùng |
|---|---|---|
\x1b[?2026h, \x1b[?2026l | Bắt đầu, kết thúc synchronized output | Cả hai renderer, mỗi khung |
\x1b[nA, \x1b[nB, \r, \r\n | Lên, xuống n dòng; về cột 0; xuống dòng (ở hàng cuối thì cuộn màn hình) | TuiMainScreen: di chuyển tương đối |
\x1b[r;cH, \x1b[nG | Đặt con trỏ tuyệt đối; về cột n | TuiAltScreen đặt từng hàng; regular đặt cột con trỏ phần cứng |
\x1b[2K, \x1b[2J\x1b[H\x1b[3J | Xoá cả dòng; xoá màn hình, về góc, xoá scrollback | Ghi đè một dòng; vẽ lại toàn bộ ở regular |
\x1b[?1049h, \x1b[?7l, \x1b[?1006h | Vào alternate screen; tắt tự xuống dòng; báo chuột dạng SGR | TuiAltScreen |
\x1b[?2004h, \x1b[>7u, \x1b[>4;2m | Bracketed paste; đẩy cờ bàn phím Kitty; modifyOtherKeys mức 2 | ProcessTerminal lúc khởi động |
\x1b]10;?\x07, \x1b]11;?\x07, \x1b]4;i;?\x07, \x1b[c | Hỏi màu chữ, màu nền, màu bảng i; DA1 làm mốc kết thúc | System theme |
\x1b]8;;url\x1b\\, \x1b]133;A\x07, \x1b]52;c;…\x07 | Hyperlink OSC 8; dấu đầu lời nhắc OSC 133; ghi clipboard OSC 52 | Markdown, tin nhắn, chọn văn bản ở fullscreen |
Data model: Component, Container và TUI#
Component: hợp đồng duy nhất
Toàn bộ thư viện dựa trên một interface rất nhỏ:
export interface Component { /** * Render the component to lines for the given viewport width * @param width - Current viewport width * @returns Array of strings, each representing a line */ render(width: number): string[]; /** Optional handler for keyboard input when component has focus. */ handleInput?(data: string): void; /** Optional normalized mouse handler. */ handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined; /** * If true, component receives key release events (Kitty protocol). * Default is false - release events are filtered out. */ wantsKeyRelease?: boolean; /** * Invalidate any cached rendering state. * Called when theme changes or when component needs to re-render from scratch. */ invalidate(): void;}render(width)là bắt buộc và thuần: nhận độ rộng, trả về các dòng. Component không biết mình nằm ở hàng nào trên màn hình, cũng không tự ghi ra terminal. README nhấn mạnh mỗi dòng “must not exceedwidthor the TUI will error” và khuyên dùngtruncateToWidth()(README.md:260). “Độ rộng” ở đây là số cột hiển thị, không phảistring.length:visibleWidth()bỏ qua mã ANSI, OSC, APC và tính ký tự CJK, emoji là hai cột (utils.ts:250).handleInput(data)nhận dữ liệu thô từ terminal (có thể là một chuỗi escape), chỉ khi component đang giữ focus.handleMouse(event)nhận sự kiện chuột đã chuẩn hoá, chỉ ở chế độ fullscreen (mục Overlay, focus và định tuyến input).wantsKeyRelease: với giao thức Kitty, terminal báo cả lúc nhả phím; mặc định TUI lọc bỏ các sự kiện này.invalidate()bắt buộc: xoá cache để lầnrender()sau dựng lại từ đầu. Ứng dụng gọitui.invalidate()(lan xuống cả cây) khi đổi theme; chính TUI gọi khi terminal báo kích thước ô ảnh mới.
Vì sao invalidate() bắt buộc mà không tuỳ chọn? Vì cache là mặc định. Mẫu chuẩn trong README là giữ cachedWidth và cachedLines, trả mảng cũ nếu độ rộng không đổi (README.md:888–915). Một component đã cache chuỗi có mã màu thì khi theme đổi sẽ vẽ màu cũ mãi mãi nếu không có đường xoá cache; tài liệu của coding agent cảnh báo đúng điều này (docs/tui.md:170).
Một hợp đồng ngầm nữa: style không được kéo sang dòng sau. TUI nối \x1b[0m (reset SGR) và \x1b]8;;\x07 (đóng hyperlink OSC 8) vào cuối mọi dòng (tui.ts:1450–1459, README.md:265). Nhờ vậy mỗi dòng độc lập: thuật toán vi sai ghi lại dòng 23 mà không cần biết dòng 22 đã để lại màu gì.
Con trỏ: CURSOR_MARKER và con trỏ giả
Component trả về chuỗi, không trả về toạ độ. Vậy làm sao TUI biết đặt con trỏ phần cứng ở đâu? Vị trí đó cần cho bộ gõ IME (tiếng Trung, Nhật, Hàn...), vì cửa sổ gợi ý của bộ gõ hiện cạnh con trỏ của terminal. Component chèn một dấu vô hình vào đúng vị trí:
export interface Focusable { /** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */ focused: boolean;}/** Type guard to check if a component implements Focusable */export function isFocusable(component: Component | null): component is Component & Focusable { return component !== null && "focused" in component;}/** * Cursor position marker - APC (Application Program Command) sequence. * This is a zero-width escape sequence that terminals ignore. * Components emit this at the cursor position when focused. * TUI finds and strips this marker, then positions the hardware cursor there. */export const CURSOR_MARKER = "\x1b_pi:c\x07";/** Fake cursor markers - zero-width APC sequences around the text drawn as the cursor. See resolveFakeCursors(). */export const FAKE_CURSOR_START = "\x1b_pi:fc\x07";export const FAKE_CURSOR_END = "\x1b_pi:/fc\x07";// CURSOR_MARKER + FAKE_CURSOR_START + text + FAKE_CURSOR_END. Truncating the line right after the cursor cell drops the end.const FOCUSED_FAKE_CURSOR = /(\x1b_pi:c\x07)\x1b_pi:fc\x07(.*?)(?:\x1b_pi:\/fc\x07|$)/s;/** Wrap text in fake cursor markers. */export function renderFakeCursor(text: string): string { return `${FAKE_CURSOR_START}${text}${FAKE_CURSOR_END}`;}CURSOR_MARKER là một chuỗi APC (ESC _ … BEL) mà terminal bỏ qua. Mỗi khung, TUI quét dấu này trong các dòng thuộc viewport, tính cột bằng visibleWidth của phần trước dấu, gỡ dấu đi, rồi đặt con trỏ phần cứng ở đó (tui.ts:1479–1497). Con trỏ phần cứng mặc định vẫn ẩn; thứ người dùng thấy là con trỏ giả: ô dưới con trỏ được bọc bởi hai dấu APC khác và TUI đổi chúng thành \x1b[7m/\x1b[27m (video đảo). Khi bật showHardwareCursor, con trỏ giả ngay sau CURSOR_MARKER bị bỏ để chỉ còn con trỏ thật (tui.ts:1504–1510). Cơ chế dấu APC thay cho việc component tự ghi \x1b[7m là thay đổi phá vỡ tương thích của 1.2.0 (CHANGELOG.md:22).
Container: ghép cây
export class Container implements Component { children: Component[] = []; private mouseLayout?: { width: number; children: Array<{ component: Component; height: number }> }; addChild(component: Component): void { this.children.push(component); } removeChild(component: Component): void { const index = this.children.indexOf(component); if (index !== -1) { this.children.splice(index, 1); } } clear(): void { this.children = []; } invalidate(): void { for (const child of this.children) { child.invalidate?.(); } } render(width: number): string[] { const lines: string[] = []; const mouseChildren: Array<{ component: Component; height: number }> = []; for (const child of this.children) { const childLines = child.render(width); mouseChildren.push({ component: child, height: childLines.length }); for (const line of childLines) { lines.push(line); } } this.mouseLayout = { width, children: mouseChildren }; return lines; }}handleMouse() ở giữa (bị lược) dùng mouseLayout, tức chiều cao của từng con ghi lại ở lần render trước, để biết con trỏ chuột rơi vào con nào mà không phải render lại.Đây là toàn bộ “bố cục” ở chế độ regular: xếp dọc, nối mảng. Không có toạ độ, không có lề, không có z-index. Một Container chứa Text, Spacer(1) và Markdown trả về các dòng của Text, rồi một dòng rỗng, rồi các dòng của Markdown. Vòng for thay cho push(...childLines) là có chủ ý: spread một mảng quá dài vượt giới hạn đối số của V8 và gây tràn ngăn xếp ở session dài (CHANGELOG.md:616). Bố cục có chiều cao ràng buộc (VStack, HStack, ScrollView) chỉ có nghĩa ở fullscreen; mục TuiAltScreen giải thích.
TUI: một interface, hai renderer
export type TuiMode = "regular" | "fullscreen";export interface TuiStopOptions { /** Leave renderer output in place for another TUI taking over the same terminal. */ preserveScreen?: boolean;}export interface TUI extends Component { readonly mode: TuiMode; children: Component[]; terminal: Terminal; onDebug?: () => void; readonly fullRedraws: number; addChild(component: Component): void; removeChild(component: Component): void; clear(): void; getShowHardwareCursor(): boolean; setShowHardwareCursor(enabled: boolean): void; getClearOnShrink(): boolean; setClearOnShrink(enabled: boolean): void; setFocus(component: Component | null): void; showOverlay(component: Component, options?: OverlayOptions): OverlayHandle; hideOverlay(): void; hasOverlay(): boolean; start(): void; stop(options?: TuiStopOptions): void; renderNow(force?: boolean): void; requestRender(force?: boolean): void; addInputListener(listener: TuiInputListener): () => void; removeInputListener(listener: TuiInputListener): void; onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void; setTerminalColorSchemeNotifications(enabled: boolean): void; queryTerminalColors(options: { timeoutMs: number; onLateReply?: (colors: TerminalColors) => void; }): Promise<TerminalColors>;}export const VIEWPORT_TUI = Symbol.for("@earendil-works/pi-tui/viewport");export interface ViewportTUI extends TUI { readonly [VIEWPORT_TUI]: true; setLayoutRoot(component: Component | undefined): void;}export function isViewportTUI(tui: TUI): tui is ViewportTUI { return (tui as Partial<ViewportTUI>)[VIEWPORT_TUI] === true;}TUI mở rộng Component: bản thân TUI là container gốc (children, addChild). Ngoài ra nó gom các trách nhiệm toàn cục: focus (setFocus), overlay, lắng nghe input thô (addInputListener trả về hàm huỷ), vòng đời (start/stop), lịch render (requestRender/renderNow), và hỏi màu terminal. fullRedraws là bộ đếm số lần vẽ lại toàn bộ, tiện cho test và cho việc đo đạc. ViewportTUI chỉ thêm setLayoutRoot(); hàm isViewportTUI() nhận ra nó qua một khoá Symbol.for(...), tức một symbol trong bảng đăng ký toàn cục, nên phép kiểm tra vẫn đúng với đối tượng tạo từ một bản sao khác của gói.
Hai cài đặt cùng kế thừa một lớp trừu tượng nội bộ, TuiBase (không export). Nó giữ focus, overlay, danh sách input listener và lịch render, còn việc ghi ra terminal được để lại cho lớp con theo kiểu template method:
export abstract class TuiBase extends Container implements TUI { abstract readonly mode: TuiMode; public terminal: Terminal; private focusedComponent: Component | null = null; private inputListeners = new Set<TuiInputListener>(); /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ public onDebug?: () => void; private renderRequested = false; private immediateRenderScheduled = false; private renderTimer: NodeJS.Timeout | undefined; private lastRenderAt = 0; private static readonly MIN_RENDER_INTERVAL_MS = 16; private showHardwareCursor = false; private clearOnShrink = false; protected fullRedrawCount = 0; protected stopped = false; protected abstract doRender(): void; protected resetRenderState(): void {} protected beforeTerminalStart(): void {} protected afterTerminalStart(): void {} protected beforeTerminalStop(_options: TuiStopOptions): void {} protected afterTerminalStop(_options: TuiStopOptions): void {}TuiMainScreen | TuiAltScreen | |
|---|---|---|
mode | "regular" | "fullscreen", là ViewportTUI |
| Bộ đệm terminal | Main screen, giữ scrollback | Alternate screen, không scrollback |
| Khung trước | previousLines: cả tài liệu | previousScreen: đúng height hàng |
| Ai cuộn | Terminal (scrollback gốc) | Ứng dụng (ScrollView, chuột, phím) |
| Chuột | Không bắt; terminal tự chọn chữ, cuộn | Bắt chuột SGR, tự chọn chữ, tự copy, tự mở link |
| Khi thoát | Để nguyên nội dung, đưa con trỏ xuống dưới | Rời alternate screen, in lại toàn bộ tài liệu vào main screen |
Terminal: thứ duy nhất TUI nói chuyện
TUI không đụng trực tiếp process.stdout; nó nhận một Terminal:
export interface Terminal { // Start the terminal with input and resize handlers start(onInput: (data: string) => void, onResize: () => void): void; // Stop the terminal and restore state stop(): void; /** * Synchronously restore terminal modes from a process `exit` handler when stop() did not run. * Writes `before`, then this terminal's own mode resets, then `after`. */ restoreOnExit?(before: string, after: string): void; /** * Drain stdin before exiting to prevent Kitty key release events from * leaking to the parent shell over slow SSH connections. * @param maxMs - Maximum time to drain (default: 1000ms) * @param idleMs - Exit early if no input arrives within this time (default: 50ms) */ drainInput(maxMs?: number, idleMs?: number): Promise<void>; // Write output to terminal write(data: string): void; // Get terminal dimensions get columns(): number; get rows(): number; // Whether Kitty keyboard protocol is active get kittyProtocolActive(): boolean; // Cursor positioning (relative to current position) moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines // Cursor visibility hideCursor(): void; // Hide the cursor showCursor(): void; // Show the cursor // Clear operations clearLine(): void; // Clear current line clearFromCursor(): void; // Clear from cursor to end of screen clearScreen(): void; // Clear entire screen and move cursor to (0,0) // Title operations setTitle(title: string): void; // Set terminal window title // Progress indicator (OSC 9;4) setProgress(active: boolean): void; /** * Report what the program is doing (OSC 7501). Sent only to terminals that support it; the latest * status is re-sent when support is confirmed or the terminal restarts. */ setProgramStatus(status: ProgramStatus): void;}Interface thật lớn hơn bản trong README: README chỉ liệt kê start, stop, write, kích thước và vài lệnh con trỏ (README.md:757–773), còn code ở v1.4.0 đòi thêm drainInput, kittyProtocolActive, setTitle, setProgress (thanh tiến trình OSC 9;4 trên tab terminal), setProgramStatus (OSC 7501, bắt buộc từ 1.1.0, CHANGELOG.md:44) và restoreOnExit tuỳ chọn. Cài đặt thật là ProcessTerminal; các test dùng một VirtualTerminal dựng trên @xterm/headless (packages/tui/test/virtual-terminal.ts), nhờ đó kiểm tra được chính xác màn hình sau mỗi khung mà không cần terminal thật.
Vòng render: requestRender, nhịp 16 ms và renderNow#
Vấn đề. Khi mô hình stream, các sự kiện message_update tới dồn dập, mỗi sự kiện chỉ mang vài token. Interactive mode gọi ui.requestRender() sau mỗi sự kiện. Nếu mỗi lời gọi là một lần vẽ, phần lớn công sức là vẽ những khung không ai kịp thấy. Ngược lại, khi người dùng gõ phím, độ trễ dù 16 ms cũng cảm nhận được.
Giải pháp. TuiBase có ba lối vào và một bộ hẹn giờ:
renderNow(force = false): void { if (force) this.resetRenderState(); this.renderRequested = false; this.cancelRenderTimer(); this.lastRenderAt = performance.now(); this.doRender();}requestRender(force = false): void { if (force) { this.resetRenderState(); this.requestImmediateRender(); return; } if (this.renderRequested) return; this.renderRequested = true; process.nextTick(() => this.scheduleRender());}private requestImmediateRender(): void { this.cancelRenderTimer(); this.renderRequested = true; if (this.immediateRenderScheduled) return; this.immediateRenderScheduled = true; process.nextTick(() => { this.immediateRenderScheduled = false; if (this.stopped || !this.renderRequested) return; // A previously queued scheduleRender() can create a timer before this // callback runs. User input must preempt that throttled frame. this.cancelRenderTimer(); this.renderRequested = false; this.lastRenderAt = performance.now(); this.doRender(); });}private scheduleRender(): void { if (this.stopped || this.renderTimer || !this.renderRequested) { return; } const elapsed = performance.now() - this.lastRenderAt; const delay = Math.max(0, TuiBase.MIN_RENDER_INTERVAL_MS - elapsed); this.renderTimer = setTimeout(() => { this.renderTimer = undefined; if (this.stopped || !this.renderRequested) { return; } this.renderRequested = false; this.lastRenderAt = performance.now(); this.doRender(); if (this.renderRequested) { this.scheduleRender(); } }, delay);}- requestRender()Đặt cờ
renderRequested; nếu cờ đã bật thì không làm gì. Lời xin đầu tiên hẹnscheduleRender()ởprocess.nextTick, nên mọi lời xin trong cùng một lượt xử lý đồng bộ gộp thành một. - scheduleRender()Tính thời gian từ khung trước; nếu chưa đủ 16 ms thì
setTimeoutcho phần còn thiếu, đủ rồi thì hẹn 0 ms. Trong lúc chờ, mọirequestRender()đều rơi vào cờ đã bật. Sau khi vẽ, nếu trong lúc vẽ có ai xin thêm, nó tự hẹn khung tiếp theo. - requestImmediateRender()Đường nhanh cho input: huỷ bộ hẹn giờ đang chờ và vẽ ở
nextTick.handleTerminalInput()gọi nó ngay sau khi component đang focus xử lý phím (tui.ts:1145–1156). Chú thích ghi lý do: trên Windows ngay cảsetTimeout(0)có thể mất trọn một nhịp 16 ms (CHANGELOG 0.84.0, CHANGELOG.md:268). - renderNow() và force
renderNow()vẽ đồng bộ ngay.force = truegọiresetRenderState()trước, xoá khung trước nên khung kế tiếp là vẽ lại toàn bộ. Interactive mode dùngrequestRender(true)sau khi quay lại từ Ctrl+Z (SIGCONT) và từ trình soạn thảo ngoài, vì lúc đó không còn biết màn hình đang hiện gì (interactive-mode.ts:4424–4428).
Cách gộp theo nhịp 16 ms (khoảng 60 khung mỗi giây) có từ 0.65.2, để sửa việc vẽ dồn dập khi stream nhiều (CHANGELOG.md:628). Hệ quả cho người viết component: gọi requestRender() thoải mái, nó rẻ; đừng tự gọi renderNow() trong vòng lặp nóng.
start() và stop()
start() (tui.ts:950–966) đăng ký một handler cho sự kiện exit của tiến trình, gọi hook beforeTerminalStart() (fullscreen vào alternate screen ở đây), bật terminal với hai callback: input đi vào handleTerminalInput(), resize chỉ là một requestRender(). Rồi nó ẩn con trỏ, hỏi kích thước ô bằng pixel nếu terminal vẽ được ảnh (CSI 16 t), và xin khung đầu tiên. Handler exit là lưới an toàn của 1.4.0: nếu tiến trình gọi thẳng process.exit() mà không qua stop(), nó vẫn ghi đồng bộ các mã tắt chế độ chuột, bàn phím Kitty, bracketed paste và alternate screen, để shell của người dùng không bị kẹt (tui.ts:574–585, CHANGELOG.md:13). stop() làm ngược lại theo cùng khung: gỡ handler, huỷ bộ hẹn giờ render, gọi beforeTerminalStop(), hiện con trỏ, dừng terminal, rồi afterTerminalStop() (tui.ts:1006–1017).
Phía terminal, ProcessTerminal.start() làm phần việc với hệ điều hành:
start(onInput: (data: string) => void, onResize: () => void): void { this.inputHandler = onInput; this.resizeHandler = onResize; // Save previous state and enable raw mode this.wasRaw = process.stdin.isRaw || false; if (process.stdin.setRawMode) { process.stdin.setRawMode(true); } process.stdin.setEncoding("utf8"); process.stdin.resume(); // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~ process.stdout.write("\x1b[?2004h"); // Set up resize handler immediately process.stdout.on("resize", this.resizeHandler); // Refresh terminal dimensions - they may be stale after suspend/resume // (SIGWINCH is lost while process is stopped). Unix only, best-effort. refreshTerminalDimensions(); // On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends // VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console // events that lose modifier information. Must run AFTER setRawMode(true) // since that resets console mode flags. this.enableWindowsVTInput(); // Query Kitty keyboard protocol and fall back to modifyOtherKeys when DA confirms no Kitty response. // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ this.queryAndEnableKittyProtocol();}Raw mode tắt việc terminal tự xử lý dòng: mỗi phím tới ngay, và Ctrl+C không còn phát SIGINT mà thành byte \x03 cho ứng dụng tự quyết (README nhắc điều này trong ví dụ đầu tiên, README.md:43). Bracketed paste (\x1b[?2004h) bảo terminal bọc nội dung dán trong \x1b[200~ … \x1b[201~, nhờ đó editor phân biệt được dán với gõ. Lệnh gửi SIGWINCH cho chính mình làm mới kích thước sau khi tiến trình bị tạm dừng rồi chạy lại, vì tín hiệu resize bị mất trong lúc dừng. Thương lượng bàn phím được trình bày ở mục Phân tích phím.
Thuật toán vi sai của TuiMainScreen#
Vấn đề. Ở chế độ regular, pi viết vào main screen như mọi chương trình dòng lệnh: nội dung cũ trôi lên scrollback, người dùng cuộn bằng con lăn của terminal và chọn chữ bằng chuột của terminal. TUI chỉ thật sự kiểm soát phần đuôi tài liệu đang nằm trong viewport. Nó phải tự nhớ con trỏ đang đứng ở dòng nào của tài liệu và viewport bắt đầu từ dòng nào, rồi di chuyển con trỏ một cách tương đối (lên, xuống n dòng), vì trên main screen hàng màn hình của một dòng tài liệu thay đổi mỗi khi nội dung cuộn.
export interface TuiMainScreenRenderState { previousLines: string[]; previousWidth: number; previousHeight: number; cursorRow: number; hardwareCursorRow: number; maxLinesRendered: number; previousViewportTop: number;}/** TUI implementation that renders into the terminal's main screen and scrollback. */export class TuiMainScreen extends TuiBase implements TUI { readonly mode = "regular" as const; private previousLines: string[] = []; private previousKittyImageIds = new Set<number>(); private previousWidth = 0; private previousHeight = 0; private cursorRow = 0; private hardwareCursorRow = 0; private maxLinesRendered = 0; private previousViewportTop = 0;previousLines: khung trước, gồm cả tài liệu chứ không chỉ phần đang hiện, ở dạng đã nối reset.previousViewportTop: chỉ số của dòng tài liệu đang nằm ở hàng trên cùng màn hình.hardwareCursorRow: dòng tài liệu mà con trỏ terminal đang thật sự đứng; mọi lệnh di chuyển tính từ đây.cursorRowlà dòng cuối của nội dung.maxLinesRendered: mức cao nhất của “vùng làm việc”, chỉ dùng cho tuỳ chọnclearOnShrink.previousKittyImageIds: id các ảnh Kitty đang hiện, để xoá khi dòng chứa chúng đổi.
Cặp captureRenderState()/restoreRenderState() chuyển state này sang một renderer mới khi interactive mode đổi qua lại giữa regular và fullscreen lúc đang chạy (tui-main-screen.ts:135–156; mục Interactive mode).
Chuẩn bị khung
protected doRender(): void { if (this.stopped) return; const width = this.terminal.columns; const height = this.terminal.rows; const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width; const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height; const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height; let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop; let viewportTop = prevViewportTop; let hardwareCursorRow = this.hardwareCursorRow; const computeLineDiff = (targetRow: number): number => { const currentScreenRow = hardwareCursorRow - prevViewportTop; const targetScreenRow = targetRow - viewportTop; return targetScreenRow - currentScreenRow; }; // Render all components to get new lines // Resolve fake cursors before compositing so overlays and line slicing see plain SGR codes let newLines = this.resolveFakeCursors(this.render(width)); // Composite overlays into the rendered lines (before differential compare) if (this.hasOverlayEntries) { newLines = this.compositeOverlays(newLines, width, height); } // Extract cursor position before applying line resets (marker must be found first) const cursorPos = this.extractCursorPosition(newLines, height); newLines = this.applyLineResets(newLines);computeLineDiff(targetRow) đổi cả hai đầu sang toạ độ màn hình (dòng tài liệu trừ dòng đầu viewport) rồi lấy hiệu: con trỏ đang ở hàng màn hình 8, cần tới hàng 5, thì kết quả là −3 và TUI ghi \x1b[3A. Thứ tự chuẩn bị có ý nghĩa. Overlay được ghép vào trước khi so sánh, nên với thuật toán, một hộp thoại nổi chỉ là vài dòng đổi nội dung. Dấu con trỏ phải được tìm trước khi nối reset, vì sau bước đó vị trí của nó trong chuỗi không còn ý nghĩa.
Chuỗi quyết định
// First render - just output everything without clearing (assumes clean screen)if (this.previousLines.length === 0 && !widthChanged && !heightChanged) { logRedraw("first render"); fullRender(false); return;}// Width changes always need a full re-render because wrapping changes.if (widthChanged) { logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`); fullRender(true); return;}// Height changes normally need a full re-render to keep the visible viewport aligned,// but Termux changes height when the software keyboard shows or hides.// In that environment, a full redraw causes the entire history to replay on every toggle.if (heightChanged && !isTermuxSession()) { logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`); fullRender(true); return;}// Content shrunk below the working area and no overlays - re-render to clear empty rows// (overlays need the padding, so only do this when no overlays are active)// Configurable via setClearOnShrink()if (this.getClearOnShrink() && newLines.length < this.maxLinesRendered && !this.hasOverlayEntries) { logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`); fullRender(true); return;}| Điều kiện (theo thứ tự xét) | Hành động | Vì sao |
|---|---|---|
Khung đầu tiên: chưa có previousLines, kích thước chưa đổi | fullRender(false): in mọi dòng, không xoá gì | Giả định màn hình sạch; giữ nguyên những gì shell in trước đó |
| Độ rộng đổi | fullRender(true) | Mọi đoạn văn phải ngắt dòng lại, không dòng cũ nào còn đúng |
| Chiều cao đổi, trừ Termux | fullRender(true) | Giữ viewport thẳng hàng; Termux đổi chiều cao mỗi lần bàn phím ảo hiện hay ẩn, vẽ lại sẽ phát lại cả lịch sử |
clearOnShrink bật, nội dung ngắn hơn maxLinesRendered, không có overlay | fullRender(true) | Xoá các hàng trống còn sót. Mặc định tắt để đỡ vẽ lại trên terminal chậm (tui.ts:608–615) |
| Không dòng nào đổi | Chỉ đặt lại con trỏ phần cứng | Không ghi khung nào |
| Chỉ còn đổi ở các dòng đã bị xoá khỏi cuối | Xoá dòng thừa tại chỗ; nếu phải lùi lên trên viewport, hoặc thừa hơn một màn hình, thì fullRender(true) | Không vẽ được những gì không nhìn thấy |
| Dòng đầu tiên đổi nằm trên viewport | fullRender(true) | Phần đã trôi vào scrollback không sửa được bằng di chuyển tương đối |
| Còn lại | Ghi từ firstChanged tới lastChanged | Đường nhanh, xảy ra ở hầu hết các khung |
Lịch sử của dòng “chiều cao đổi” cho thấy đây là thoả hiệp chứ không phải chân lý. Ở 0.51.1, đổi chiều cao thôi không vẽ lại toàn bộ để bớt nhấp nháy (CHANGELOG.md:904); ở 0.62.0 lại bật lại vì editor và footer bị lệch khi resize (CHANGELOG.md:778); ngoại lệ Termux có sau đó.
Vẽ lại toàn bộ
const fullRender = (clear: boolean): void => { this.fullRedrawCount += 1; const output = new BoundedTerminalWriter((data) => this.terminal.write(data)); output.append("\x1b[?2026h"); // Begin synchronized output if (clear) { output.append(this.deleteKittyImages(this.previousKittyImageIds)); output.append("\x1b[2J\x1b[H\x1b[3J"); // Clear screen, home, then clear scrollback } for (let i = 0; i < newLines.length; i++) { if (i > 0) output.append("\r\n"); const line = newLines[i]; output.append(line); } output.append("\x1b[?2026l"); // End synchronized output output.flush(); this.cursorRow = Math.max(0, newLines.length - 1); this.hardwareCursorRow = this.cursorRow; // Reset max lines when clearing, otherwise track growth if (clear) { this.maxLinesRendered = newLines.length; } else { this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); } const bufferLength = Math.max(height, newLines.length); this.previousViewportTop = Math.max(0, bufferLength - height); this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; this.previousKittyImageIds = this.collectKittyImageIds(newLines); this.previousWidth = width; this.previousHeight = height;};\x1b[2J\x1b[H\x1b[3J xoá màn hình, đưa con trỏ về góc trên trái, rồi xoá scrollback; sau đó toàn bộ tài liệu được in lại. Ở một session dài, đó là hàng nghìn dòng. BoundedTerminalWriter chia đầu ra thành từng khúc 1 MiB và không cắt đôi cặp surrogate UTF-16, để một lần vẽ lớn không bao giờ dựng ra một chuỗi vượt giới hạn độ dài chuỗi của V8 (tui-main-screen.ts:9–17).
Một chi tiết nhỏ quyết định nhánh nào được chọn: resetRenderState() đặt previousWidth = -1 chứ không phải 0 (tui-main-screen.ts:158–166). TUI mới tạo có previousWidth = 0, được hiểu là “chưa vẽ lần nào” và đi vào nhánh khung đầu tiên không xoá màn hình. Còn sau requestRender(true), giá trị −1 khác mọi độ rộng thật nên được hiểu là “độ rộng đổi”, đi vào nhánh có xoá.
Tìm vùng thay đổi
// Find first and last changed lineslet firstChanged = -1;let lastChanged = -1;const maxLines = Math.max(newLines.length, this.previousLines.length);for (let i = 0; i < maxLines; i++) { const oldLine = i < this.previousLines.length ? this.previousLines[i] : ""; const newLine = i < newLines.length ? newLines[i] : ""; if (oldLine !== newLine) { if (firstChanged === -1) { firstChanged = i; } lastChanged = i; }}const appendedLines = newLines.length > this.previousLines.length;if (appendedLines) { if (firstChanged === -1) { firstChanged = this.previousLines.length; } lastChanged = newLines.length - 1;}if (firstChanged !== -1) { const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines); firstChanged = expandedRange.firstChanged; lastChanged = expandedRange.lastChanged;}const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;Phép so sánh là so chuỗi tại cùng chỉ số, không có thuật toán dãy con chung dài nhất kiểu diff. Nếu một dòng được chèn vào giữa, mọi dòng bên dưới lệch đi một chỉ số và đều bị coi là đổi. Cách đơn giản này chấp nhận được nhờ quy tắc ở mục sau: firstChanged không được nằm trên viewport, nên đoạn phải ghi lại không bao giờ dài hơn một màn hình cộng số dòng mới. appendStart đánh dấu trường hợp chỉ có dòng nối thêm ở cuối, không dòng cũ nào đổi.
Hai lối tắt và một lối ra
// No changes - but still need to update hardware cursor position if it movedif (firstChanged === -1) { this.positionHardwareCursor(cursorPos, newLines.length); this.previousViewportTop = prevViewportTop; this.previousHeight = height; return;}// Differential rendering can only touch what was actually visible.// If the first changed line is above the previous viewport, we need a full redraw.if (firstChanged < prevViewportTop) { logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`); fullRender(true); return;}Kiểm tra ở dòng 452 là giới hạn căn bản của chế độ regular. Những dòng đã trôi vào scrollback thuộc về terminal. Không có lệnh di chuyển tương đối nào với tới chúng, và nếu có cũng không an toàn vì người dùng có thể đang cuộn xem. Cách duy nhất để sửa là xoá sạch và in lại.
Ghi phần thay đổi
// Render from first changed line to end// Keep updates wrapped in synchronized output while writing bounded chunks.const output = new BoundedTerminalWriter((data) => this.terminal.write(data));output.append("\x1b[?2026h"); // Begin synchronized outputoutput.append(this.deleteChangedKittyImages(firstChanged, lastChanged));const prevViewportBottom = prevViewportTop + height - 1;const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;if (moveTargetRow > prevViewportBottom) { const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop)); const moveToBottom = height - 1 - currentScreenRow; if (moveToBottom > 0) { output.append(`\x1b[${moveToBottom}B`); } const scroll = moveTargetRow - prevViewportBottom; output.append("\r\n".repeat(scroll)); prevViewportTop += scroll; viewportTop += scroll; hardwareCursorRow = moveTargetRow;}// Move cursor to first changed line (use hardwareCursorRow for actual position)const lineDiff = computeLineDiff(moveTargetRow);if (lineDiff > 0) { output.append(`\x1b[${lineDiff}B`); // Move down} else if (lineDiff < 0) { output.append(`\x1b[${-lineDiff}A`); // Move up}output.append(appendStart ? "\r\n" : "\r"); // Move to column 0// Only render changed lines (firstChanged to lastChanged), not all lines to end// This reduces flicker when only a single line changes (e.g., spinner animation)const renderEnd = Math.min(lastChanged, newLines.length - 1);for (let i = firstChanged; i <= renderEnd; i++) { if (i > firstChanged) output.append("\r\n"); const line = newLines[i]; const isImage = isImageLine(line); output.append("\x1b[2K"); // Clear current line output.append(line);}- Cuộn trước nếu cần (465–476). Nếu dòng đích nằm dưới đáy viewport cũ, TUI xuống hàng cuối rồi ghi đủ số
\r\nđể terminal cuộn. LệnhCSI n Bkhông làm màn hình cuộn khi con trỏ đã ở hàng cuối; chỉ ký tự xuống dòng mới làm được. - Dòng nối thêm (464, 486). Cùng lý do: khi chỉ có dòng mới ở cuối, TUI đứng ở dòng ngay trước rồi ghi
\r\n, chứ không “di chuyển xuống” một dòng chưa tồn tại. - Ghi đè (517, 546). Mỗi dòng:
\x1b[2Kxoá cả hàng, rồi in nội dung mới. Nhờ xoá trước, TUI không cần biết dòng cũ dài bao nhiêu. - Dừng ở
lastChanged(488–490). Chú thích trong code ghi rõ: chỉ ghi các dòng đổi để giảm nhấp nháy khi chỉ một dòng giữa màn hình đổi, ví dụ spinner.
Dọn dòng thừa và ghi sổ
// Track where cursor ended up after renderinglet finalCursorRow = renderEnd;// If we had more lines before, clear them and move cursor backif (this.previousLines.length > newLines.length) { // Move to end of new content first if we stopped before it if (renderEnd < newLines.length - 1) { const moveDown = newLines.length - 1 - renderEnd; output.append(`\x1b[${moveDown}B`); finalCursorRow = newLines.length - 1; } const extraLines = this.previousLines.length - newLines.length; for (let i = newLines.length; i < this.previousLines.length; i++) { output.append("\r\n\x1b[2K"); } // Move cursor back to end of new content output.append(`\x1b[${extraLines}A`);}output.append("\x1b[?2026l"); // End synchronized output// Track cursor position for next render// cursorRow tracks end of content (for viewport calculation)// hardwareCursorRow tracks actual terminal cursor position (for movement)this.cursorRow = Math.max(0, newLines.length - 1);this.hardwareCursorRow = finalCursorRow;// Track terminal's working area (grows but doesn't shrink unless cleared)this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);// Position hardware cursor for IMEthis.positionHardwareCursor(cursorPos, newLines.length);this.previousLines = newLines;this.previousKittyImageIds = this.collectKittyImageIds(newLines);this.previousWidth = width;this.previousHeight = height;Dòng 608 là chỗ TUI “theo dõi” cú cuộn mà chính nó gây ra: nếu con trỏ đã xuống quá đáy viewport cũ thì terminal đã cuộn, nên dòng đầu viewport mới là finalCursorRow - height + 1. Cuối cùng positionHardwareCursor() đưa con trỏ terminal tới chỗ CURSOR_MARKER, bằng CSI A/B cho hàng và CSI G (cột tuyệt đối) cho cột, rồi ghi lại hardwareCursorRow (tui-main-screen.ts:624–655). Lệnh này được ghi sau khi đã đóng synchronized output. Vì vậy sau mỗi khung, con trỏ terminal đứng ở ô soạn thảo chứ không ở cuối nội dung, và khung tiếp theo bắt đầu tính đường đi từ đó.
Trace: tin nhắn đang stream dài thêm một dòng#
Giờ ghép các mảnh lại trong một kịch bản cụ thể ở chế độ regular. Terminal rộng 40 cột, cao 12 hàng. Session đã có hơn một màn hình nội dung; mô hình đang stream một đoạn văn, và token vừa tới làm đoạn văn xuống thêm một dòng. Các con số bên dưới là ví dụ minh hoạ, nhưng mỗi bước đi đúng nhánh code đã trích ở mục trước.
1. Từ token tới requestRender()
- message_update
AgentSessionphát sự kiện mang assistant message từng phần (xem chương AgentSession).InteractiveMode.handleEvent()lưu message và gọistreamingComponent.updateContent(message, true)(interactive-mode.ts:3481–3484). - updateContent()
AssistantMessageComponentxoá container nội dung rồi dựng lại: mộtSpacer(1), rồi mộtMarkdownmới cho mỗi khối text (assistant-message.ts:95–117). Chưa có byte nào ra terminal; chỉ có cây đổi. - requestRender()
handleEvent()gọiui.requestRender()(:3513). Giả sử khung trước mới vẽ cách đây 5 ms:scheduleRender()hẹn 11 ms. Nhữngmessage_updatetới trong 11 ms đó chỉ thay cây, không thêm khung nào. - doRender()Bộ hẹn giờ chạy;
TuiMainScreen.doRender()gọirender(40)trên cả cây.
2. render() trả về gì
Gốc là chính TUI, một container với bảy con: tài liệu (header, danh sách tài nguyên, chat), tin nhắn chờ, trạng thái, widget phía trên, editor, widget phía dưới, footer (mục Interactive mode). Khi đang stream, tin nhắn chờ và trạng thái rỗng (chỉ báo “working” được nhúng vào viền trên của editor), widget phía trên là một Spacer(1), editor ba dòng, footer hai dòng. AssistantMessageComponent.render() còn gắn dấu OSC 133 vào dòng đầu (A) và dòng cuối (B, C) của tin nhắn không có tool call (assistant-message.ts:80–89). Đuôi của hai khung:
Ví dụ minh hoạ: đuôi khung trước (30 dòng) và khung mới (31 dòng), 40 × 12 dòng khung trước khung mới 18 nội dung cũ (hàng 0 màn hình) nội dung cũ .. ... ... 21 [A] dòng trống đầu tin nhắn [A] dòng trống đầu tin nhắn 22 " Hàm render() trả về một mảng" " Hàm render() trả về một mảng" 23 [B][C] " chuỗi, mỗi chuỗi là" " chuỗi, mỗi chuỗi là một dòng" đổi 24 Spacer của widget [B][C] " đã cắt vừa độ rộng." đổi 25 viền trên editor ── ⠋ Working Spacer của widget đổi 26 ‸ dòng nhập (CURSOR_MARKER) viền trên editor ── ⠙ Working đổi 27 viền dưới editor ‸ dòng nhập (CURSOR_MARKER) đổi 28 footer: thư mục, nhánh git viền dưới editor đổi 29 footer: token, ngữ cảnh (hàng 11) footer: thư mục, nhánh git đổi 30 footer: token, ngữ cảnh mới [A] = \x1b]133;A\x07 [B][C] = \x1b]133;B\x07\x1b]133;C\x07 mỗi dòng Markdown được đệm dấu cách đủ 40 cột; mỗi dòng kết thúc bằng \x1b[0m\x1b]8;;\x07
[B][C], và đoạn văn ngắt dòng lại khi có thêm chữ. Mọi dòng phía dưới lệch xuống một chỉ số.3. Thuật toán quyết định
| Bước | Giá trị | Nhánh |
|---|---|---|
| Kích thước | 40 × 12, như khung trước | Không vẽ lại toàn bộ vì resize |
| Viewport và con trỏ | prevViewportTop = 18, hardwareCursorRow = 26 (dòng nhập, do khung trước đặt) | |
| Vùng đổi | firstChanged = 23; có dòng nối thêm nên lastChanged = 30 | appendStart = false vì 23 ≠ 30 |
| Trên viewport? | 23 < 18 là sai | Đi đường nhanh |
| Cần cuộn trước? | prevViewportBottom = 18 + 12 − 1 = 29; đích 23 không vượt đáy | Không |
| Di chuyển | (23 − 18) − (26 − 18) = −3 | \x1b[3A, rồi \r |
| Phạm vi ghi | renderEnd = min(30, 30) = 30 | Tám dòng, 23 tới 30 |
4. Byte ghi ra
Ví dụ minh hoạ: ba lần ghi ra stdout (ESC viết là \x1b, R = \x1b[0m\x1b]8;;\x07)
lần 1: BoundedTerminalWriter, một khúc
\x1b[?2026h bắt đầu synchronized output
\x1b[3A\r từ dòng 26 lên dòng 23, cột 0
\x1b[2K" chuỗi, mỗi chuỗi là một dòng"…R dòng 23 ở hàng màn hình 5
\r\n\x1b[2K[B][C]" đã cắt vừa độ rộng."…R dòng 24, hàng 6
\r\n\x1b[2KR dòng 25, Spacer
\r\n\x1b[2K── ⠙ Working ───…R dòng 26, hàng 8
\r\n\x1b[2K\x1b[7m \x1b[27m + 39 dấu cách R dòng 27, con trỏ giả
\r\n\x1b[2K────…R dòng 28
\r\n\x1b[2K(footer: thư mục)R dòng 29, hàng 11, hàng cuối
\r\n\x1b[2K(footer: token)R dòng 30: \r\n ở hàng cuối làm terminal
cuộn lên một hàng; dòng 18 vào scrollback
\x1b[?2026l kết thúc synchronized output
lần 2: positionHardwareCursor()
\x1b[3A\x1b[1G từ dòng 30 lên dòng 27, cột 1
lần 3
\x1b[?25l con trỏ phần cứng vẫn ẩn
5. Sau khung
| Trường | Trước | Sau |
|---|---|---|
previousLines.length | 30 | 31 |
previousViewportTop | 18 | 19 = max(18, 30 − 12 + 1) |
hardwareCursorRow | 26 | 27, do positionHardwareCursor() |
cursorRow | 29 | 30 |
maxLinesRendered | 30 | 31 |
Giả sử trong cùng khung đó còn có một thay đổi ở dòng 10, ví dụ người dùng vừa bấm phím mở rộng output của tool, hay một hàng tool đã trôi lên trên viewport đổi nội dung. Khi đó firstChanged = 10 < 18 và TUI đi nhánh vẽ lại toàn bộ: \x1b[?2026h, \x1b[2J\x1b[H\x1b[3J, cả 31 dòng nối bằng \r\n, \x1b[?2026l. Với 31 dòng thì rẻ; với một session năm nghìn dòng thì terminal phải nhận lại cả năm nghìn dòng, và trên terminal không hỗ trợ synchronized output người dùng thấy lịch sử chạy lại.
TuiAltScreen: chế độ toàn màn hình#
Từ 1.0.0, interactive mode chạy fullscreen mặc định; muốn giữ scrollback như cũ thì đặt tuiMode là "regular" hoặc chạy --tui-mode regular (coding-agent CHANGELOG.md:328, :345). Chế độ này dùng TuiAltScreen, và khác TuiMainScreen ở ba điểm căn bản. Khung hình không còn là cả tài liệu mà đúng height hàng. Màn hình được vẽ bằng toạ độ tuyệt đối. Và vì alternate screen không có scrollback, không có gì bị “đóng băng”: dòng nào của tài liệu cũng vẽ lại được bất cứ lúc nào.
Vào và ra alternate screen
const term = process.env.TERM?.toLowerCase() ?? "";// Multiplexers can lag when every pointer movement is forwarded. Button-motion// tracking preserves clicks, wheel events, selections, and scrollbar dragging.const mouseSequence = process.env.TMUX !== undefined || process.env.ZELLIJ !== undefined || process.env.STY !== undefined || term.startsWith("tmux") || term.startsWith("screen") ? ENABLE_BUTTON_MOTION_MOUSE : ENABLE_ALL_MOTION_MOUSE;this.terminal.write( `${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? mouseSequence : ""}\x1b[2J\x1b[H\x1b[?25l`,);Giá trị của các hằng nằm ở đầu file (tui-alt-screen.ts:63–77). Mã chuột gồm 1000 (nhấn và nhả), 1002 (kéo khi giữ nút), 1003 (mọi chuyển động), 1004 (terminal báo được hay mất focus bằng \x1b[I/\x1b[O) và 1006 (toạ độ dạng SGR, không giới hạn 223 cột như dạng cũ). Dưới tmux, Zellij hay GNU Screen, TUI bỏ 1003 vì chuyển tiếp mọi chuyển động chuột làm các multiplexer này chậm (CHANGELOG.md:233). Tắt autowrap (\x1b[?7l) để việc ghi kín cột cuối không làm terminal tự xuống dòng và cuộn. Cũng trong hook này, nếu terminal là iTerm2 thì khả năng vẽ ảnh bị tạm tắt: giao thức ảnh của iTerm2 không xoá hay cắt được một ảnh đã đặt, nên khi viewport cuộn ảnh cũ sẽ đè lên chữ mới (tui-alt-screen.ts:356–360, README.md:688).
protected override afterTerminalStop(options: TuiStopOptions): void { if (!this.altScreenActive) return; this.altScreenActive = false; if (options.preserveScreen) { this.terminal.write(`${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`); } else { const width = Math.max(1, this.terminal.columns); const documentLines = this.resolveFakeCursors(this.render(width)).map((line) => line.replace(OSC133_ZONE_PREFIX, ""), ); this.lastDocument = this.applyLineResets(documentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map( (line) => (isImageLine(line) || visibleWidth(line) <= width ? line : sliceByColumn(line, 0, width, true)), ); let buffer = `${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}${DISABLE_AUTOWRAP}`; for (let row = 0; row < this.lastDocument.length; row++) { if (row > 0) buffer += "\r\n"; buffer += `\r\x1b[2K${this.lastDocument[row] ?? ""}`; } buffer += `\x1b[0m${ENABLE_AUTOWRAP}\r\n\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`; this.terminal.write(buffer); }Khi thoát, alternate screen biến mất cùng nội dung của nó. Để nội dung không mất khỏi terminal, mặc định TuiAltScreen render cả cây một lần nữa ở dạng tài liệu không giới hạn chiều cao (gỡ dấu OSC 133 và dấu con trỏ), rồi in vào main screen (README.md:62); gọi stop({ preserveScreen: true }) thì bỏ bước này. Coding agent dùng đúng lựa chọn đó: nó dừng TuiAltScreen mà giữ màn hình, chuyển hẳn sang một TuiMainScreen, và để renderer này in transcript; nếu phiên từng chạy ở regular rồi mới chuyển sang fullscreen, state vi sai đã chụp giúp phần in trước đó ở main screen không bị in lặp (mục Interactive mode).
Bố cục: VStack, HStack, ScrollView
Ở chế độ regular, bố cục là phép nối dọc vô hạn và terminal lo việc cuộn. Ở fullscreen, ứng dụng phải nói vùng nào được bao nhiêu hàng, vùng nào cuộn được. pi-tui có ba component bố cục, chỉ có nghĩa đầy đủ trên ViewportTUI (README.md:121). Chúng tự khai báo bằng một phương thức đặt tên bằng symbol LAYOUT_NODE, trả về một nút bố cục: vstack/hstack với danh sách mục, hoặc scroll với một con duy nhất (layout-node.ts:10–42). Mỗi mục của stack có basis, grow, shrink, minSize, maxSize và visible, như flexbox thu gọn; allocateStackSizes() chia chỗ theo trọng số (stack.ts:135–154). Bố cục của coding agent:
export function createChatViewport(options: ChatViewportOptions): ChatViewport { const transcript = new ScrollView(options.document, { follow: "end", primary: true, overscroll: "chain", scrollbar: options.scrollbar ?? "auto", ...(options.scrollbarTrackStyle === undefined ? {} : { scrollbarTrackStyle: options.scrollbarTrackStyle }), ...(options.scrollbarThumbStyle === undefined ? {} : { scrollbarThumbStyle: options.scrollbarThumbStyle }), }); const dock = new VStack([ { component: options.pendingMessages, shrink: 1, minSize: 0 }, { component: options.status, shrink: 1, minSize: 0 }, ...(options.widgetsAbove === undefined ? [] : [{ component: options.widgetsAbove, shrink: 1, minSize: 0 }]), { component: options.editor, shrink: 1, minSize: 3 }, ...(options.widgetsBelow === undefined ? [] : [{ component: options.widgetsBelow, shrink: 1, minSize: 0 }]), { component: options.footer, shrink: 1, minSize: 0 }, ]); return { transcript, root: new VStack([ { component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 }, { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 }, ]), };}Transcript có basis: 0, grow: 1: nó lấy mọi hàng còn lại. Dock (tin nhắn chờ, trạng thái, widget, editor, footer) có basis: "auto": nó cao đúng bằng nội dung, và mỗi phần có thể co lại, riêng editor không dưới ba hàng. ScrollView của transcript là primary, tức là nơi nhận phím cuộn và con lăn khi con trỏ chuột không nằm trên vùng cuộn nào khác, với follow: "end": dính đáy khi đang ở đáy.
if (node.type === "scroll") { const previousScrollTop = node.state.scrollTop; const contentWidth = node.state.getContentWidth(safeWidth); const childBox = layoutComponent( context, node.component, x, y - previousScrollTop, contentWidth, undefined, clip, ); const contentHeight = childBox.rect.height; const viewportHeight = height === undefined ? contentHeight : Math.max(0, Math.floor(height)); node.state.updateLayout(contentHeight, viewportHeight, context.requestRender); translateBox(childBox, previousScrollTop - node.state.scrollTop); const scrollView = node.state as ScrollView; if (node.state.primary || !context.primaryScrollView) context.primaryScrollView = scrollView; const rect = { x, y, width: safeWidth, height: viewportHeight }; const childClip = intersect(clip, rect); const box: LayoutBox = { component, rect, clip: childClip, children: [childBox], scrollView, scrollContentLines: renderCached(context, node.component, contentWidth), layer: 0, }; childBox.parent = box; updateClips(childBox, childClip); return box;Con của ScrollView được bố trí với chiều cao không giới hạn tại toạ độ y - scrollTop. Biết chiều cao nội dung rồi, updateLayout() quyết định scrollTop mới, và cây con được dịch theo độ chênh:
updateLayout(contentHeight: number, viewportHeight: number, requestRender: () => void): void { this.contentHeight = Math.max(0, Math.floor(contentHeight)); this.currentViewportHeight = Math.max(0, Math.floor(viewportHeight)); this.requestRenderCallback = requestRender; const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight); if (this.followingEnd) this.currentScrollTop = maxScrollTop; else this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop)); if (this.currentScrollTop < maxScrollTop) this.followSuppressedAtEnd = false; if (this.followEnd && this.currentScrollTop === maxScrollTop && !this.followSuppressedAtEnd) { this.followingEnd = true; } if (this.contentHeight <= this.currentViewportHeight) this.hideTransientScrollbar();}Đây là toàn bộ cơ chế “tự cuộn theo output khi đang ở đáy, giữ nguyên chỗ khi người dùng đã cuộn lên” mà README mô tả (README.md:751). Cuộn lên bằng con lăn gọi scrollBy(), hàm này tắt followingEnd nếu vị trí mới không phải đáy, và trả về phần delta chưa dùng để chuyển tiếp cho ScrollView bao ngoài (scroll-view.ts:148–162).
renderLayoutFrame() dựng một cây LayoutBox (toạ độ, vùng cắt, các dòng của từng component) rồi sơn nó vào đúng height chuỗi (layout.ts:379–408). Mỗi khung có một cache render riêng, nên mỗi component được render tối đa một lần cho mỗi độ rộng (layout.ts:68–81); cache dài hạn vẫn là cache bên trong component. Khi sơn, một hộp rộng kín màn hình lên một hàng chưa có gì thì gán thẳng tham chiếu chuỗi thay vì ghép lại qua phân tích ANSI và grapheme (layout.ts:345–354), một thay đổi giảm lượng cấp phát mỗi khung khoảng 9 tới 18 lần (CHANGELOG.md:209). Không có layout root thì TUI tự bọc các con trong một ScrollView ngầm, theo đáy và primary (tui-alt-screen.ts:262–269).
doRender(): so từng hàng
protected override doRender(): void { if (this.stopped || !this.altScreenActive) return; const width = Math.max(1, this.terminal.columns); const height = Math.max(1, this.terminal.rows); const root = this.layoutRoot ?? this.implicitScrollView; let nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender()); if (this.refreshSearch(nextLayout)) { nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender()); } // Resolve fake cursors before highlighting and compositing, which only track SGR codes let screen = this.resolveFakeCursors(nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, ""))); screen = this.applySearchHighlights(screen, nextLayout); screen = this.compositeScrollToEndIndicator(screen, nextLayout, width); screen = this.compositeOverlays(screen, width, height); if (screen.length > height) screen = screen.slice(screen.length - height); screen = this.applySelection(screen, nextLayout); screen = this.compositeFlashes(screen, width, height); const cursorPos = this.extractCursorPosition(screen, height); screen = this.applyLineResets(screen).map((line) => { if (isImageLine(line) || visibleWidth(line) <= width) return line; return sliceByColumn(line, 0, width, true); }); const fullRedraw = this.previousScreen.length === 0 || this.previousScreenWidth !== width || this.previousScreenHeight !== height; const changedRows = screen.map((line, row) => line !== this.previousScreen[row]); let buffer = BEGIN_SYNCHRONIZED_OUTPUT; if (fullRedraw) { this.fullRedrawCount += 1; const clearImages = this.imageProtocol === "kitty" && hadUploadedKittyImages ? deleteAllKittyPlacements() : this.deleteKittyImages(); buffer += `${clearImages}\x1b[2J`; } else { for (let row = 0; row < height; row++) { if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue; buffer += `\x1b[${row + 1};1H\x1b[2K${preparedKittyScreen.lines[row] ?? ""}`; } } if (cursorPos) { buffer += `\x1b[${cursorPos.row + 1};${Math.min(width, cursorPos.col) + 1}H`; buffer += this.getShowHardwareCursor() ? "\x1b[?25h" : "\x1b[?25l"; } else { buffer += "\x1b[?25l"; } buffer += END_SYNCHRONIZED_OUTPUT; this.terminal.write(buffer);- Dựng màn hình
renderLayoutFrame(root, width, height)trả về đúngheightdòng. Nếu đang tìm kiếm và kết quả cần cuộn để lộ ra, khung được dựng lại lần hai. - Trang tríGỡ dấu OSC 133 ở đầu dòng, đổi con trỏ giả, tô kết quả tìm kiếm, ghép nhãn “nhảy tới cuối”, ghép overlay, cắt về
heighthàng, tô vùng chọn, ghép thông báo nổi (flash). - Chuẩn hoáLấy vị trí con trỏ, nối reset, và cắt dòng quá rộng. Chế độ regular ném lỗi ở tình huống này; fullscreen thì im lặng cắt bớt.
- So và ghiKhung đầu hoặc đổi kích thước thì
\x1b[2Jvà vẽ mọi hàng. Nếu không, chỉ những hàng có chuỗi khácpreviousScreen[row], mỗi hàng bằng\x1b[r;1H\x1b[2Krồi nội dung. Cuối cùng đặt con trỏ tuyệt đối và đóng synchronized output, tất cả trong một lần ghi.
Cùng sự kiện ở fullscreen
Chạy lại kịch bản của mục Trace trên TuiAltScreen, vẫn 40 × 12. Dock cao 6 hàng (Spacer, ba hàng editor, hai hàng footer), nên allocateStackSizes() cho transcript basis 0 nâng lên minSize 1, rồi chia phần dư 5 hàng cho mục duy nhất có grow: transcript được 6 hàng, dock 6 hàng. Tài liệu bây giờ chỉ gồm header, danh sách tài nguyên và chat, tức 24 dòng cũ (0–23) và 25 dòng mới. Đang theo đáy, nên scrollTop đi từ 24 − 6 = 18 lên 25 − 6 = 19.
Ví dụ minh hoạ: cùng khung đó ở TuiAltScreen, 40 × 12 hàng trước (scrollTop 18) sau (scrollTop 19) 0 tài liệu 18 tài liệu 19 ghi 1 tài liệu 19 tài liệu 20 ghi 2 tài liệu 20 tài liệu 21 (dòng trống) ghi 3 tài liệu 21 (dòng trống) tài liệu 22 ghi 4 tài liệu 22 tài liệu 23 ghi 5 tài liệu 23 tài liệu 24 ghi 6 Spacer Spacer giữ 7 ── ⠋ Working ── ── ⠙ Working ── ghi, vì spinner đổi khung 8 ‸ dòng nhập ‸ dòng nhập giữ 9 viền dưới editor viền dưới editor giữ 10 footer: thư mục footer: thư mục giữ 11 footer: token footer: token giữ \x1b[?2026h \x1b[1;1H\x1b[2K(tài liệu 19)R \x1b[2;1H\x1b[2K(tài liệu 20)R … \x1b[6;1H\x1b[2K(tài liệu 24)R \x1b[8;1H\x1b[2K── ⠙ Working ───…R \x1b[9;1H\x1b[?25l con trỏ về dòng nhập (hàng 8), vẫn ẩn \x1b[?2026l
Ở kịch bản này hai chế độ tốn gần như nhau: tám dòng so với bảy hàng. Khác biệt lộ ra ở các trường hợp khác. Khi người dùng đã cuộn lên đọc lại, scrollTop đứng yên, không hàng nào của transcript đổi, và thứ được ghi ra chỉ còn lệnh đặt con trỏ, cộng hàng spinner nếu nó vừa đổi khung. Một thay đổi ở bất kỳ đâu trong tài liệu tốn tối đa height hàng, không bao giờ là phát lại cả lịch sử. Đổi kích thước cửa sổ cũng chỉ vẽ lại một màn hình.
Bàn phím của viewport
Constructor đăng ký handleViewportInput() làm input listener đầu tiên (tui-alt-screen.ts:281), nên nó thấy mọi byte trước component đang focus. Báo focus và sự kiện chuột bị nuốt ở đây; phần còn lại là các phím của viewport (tìm kiếm, cuộn trang, nửa trang, từng dòng, lời nhắc trước và sau, đầu và cuối), mỗi phím khớp thì trả về { consume: true } để editor không nhận nữa (tui-alt-screen.ts:739–805).
Các phím này cố ý che phím cùng tên của editor ở fullscreen (keybindings.ts:159): PageUp cuộn transcript chứ không cuộn ô soạn thảo; editor vẫn còn Ctrl+PageUp. Khi một overlay khác ô tìm kiếm đang giữ focus, các phím cuộn được nhường cho overlay (:680–682). Hai phím “lời nhắc trước/sau” dò dấu \x1b]133;A mà tin nhắn của người dùng và của assistant gắn vào dòng đầu, và nhảy tới đó (:517–529). Ở chế độ regular, chính các dấu đó được ghi thẳng ra terminal, nên terminal nào hỗ trợ OSC 133 tự có phím nhảy giữa các lời nhắc. Ctrl+Shift+F mở ô tìm kiếm, bản thân nó là một overlay neo góc trên phải, rộng 40% (:531–556); chỉ mục tìm kiếm giữ kết quả cũ chừng nào các dòng của transcript chưa đổi (alt-screen-search.ts:155).
Chuột: cuộn, chọn, copy, mở link
Khi bắt chuột, ứng dụng nhận lại mọi việc terminal vốn tự làm. handleMouseEvent() xét theo thứ tự: component đang “bắt” chuỗi kéo thả; nút của ô tìm kiếm; overlay trên cùng dưới con trỏ; nhãn “nhảy tới cuối”; thanh cuộn (kéo, bấm vào rãnh, phóng to khi rê qua); component trong bố cục (handleMouse); dán bằng chuột phải trên Windows; cuối cùng là chọn văn bản (:912–973). Con lăn đi đường riêng: phần delta chưa dùng chuyển từ ScrollView trong ra ScrollView ngoài, trừ khi vùng trong đặt overscroll: "contain" (:1004–1016); Alt cộng con lăn cuộn gấp năm.
Vùng chọn được neo bằng toạ độ trong nội dung của ScrollView chứ không phải toạ độ màn hình, nên nó đúng cả khi transcript cuộn. Kéo tới mép trên hay dưới thì tự cuộn mỗi 50 ms. Bấm đúp chọn một từ và giữ nguyên đường dẫn hay từ nối bằng gạch ngang, vì / và - được coi là phần của từ (:83–85); bấm ba lần chọn cả dòng. Nhả chuột sau một cú bấm (không kéo) lên một hyperlink OSC 8 thì mở link; còn lại thì copy nếu copyOnSelect bật. copyTextToClipboard() ưu tiên hàm clipboard do ứng dụng truyền vào và chỉ báo “Copied!” khi hàm đó thành công; không có hàm nào thì ghi OSC 52 (tui-alt-screen.ts:1480–1497).
Chú thích giải thích vì sao không tin OSC 52: một lần ghi \x1b]52;c;… có thể hiện “Copied!” trong khi clipboard hệ thống không đổi, ví dụ trên Terminal.app của macOS hay tmux không chuyển tiếp OSC 52. Coding agent vì thế truyền vào một copySelection dùng clipboard native và các công cụ của hệ điều hành (tui-renderer.ts:39–46).
Overlay, focus và định tuyến input#
Overlay: vẽ đè lên dòng, không có lớp
Vấn đề. Hộp thoại, menu, ô tìm kiếm cần nổi lên trên nội dung mà không đẩy nội dung đi chỗ khác. Trong một mô hình chỉ có dòng, không có lớp (layer) hay z-index. Overlay vì thế phải được ghép vào chính các dòng của khung: cắt dòng nền tại cột bắt đầu, chèn dòng của overlay, nối phần còn lại của dòng nền.
type OverlayStackEntry = { component: Component; options?: OverlayOptions; preFocus: Component | null; hidden: boolean; focusOrder: number; bounds?: OverlayBounds;}; showOverlay(component: Component, options?: OverlayOptions): OverlayHandle { const entry: OverlayStackEntry = { component, ...(options === undefined ? {} : { options }), preFocus: this.focusedComponent, hidden: false, focusOrder: ++this.focusOrderCounter, }; this.overlayStack.push(entry); // Only focus if overlay is actually visible if (!options?.nonCapturing && this.isOverlayVisible(entry)) { this.setFocus(component); } this.hideTerminalCursor(); this.requestRender();Mỗi overlay nhớ preFocus, component đang giữ focus lúc nó hiện ra, để trả focus khi nó đóng; và focusOrder, một bộ đếm tăng dần quyết định overlay nào nằm trên. Tuỳ chọn vị trí và kích thước (width, maxHeight, anchor, row/col tuyệt đối hoặc phần trăm, margin, visible(w, h), nonCapturing) nằm trong OverlayOptions (tui.ts:254–290); thứ tự ưu tiên khi chúng xung đột được README ghi rõ (README.md:239–243). Ghép vào khung diễn ra trong compositeOverlays():
const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);for (const entry of visibleEntries) { const { component, options } = entry; // Get layout with height=0 first to determine width and maxHeight // (width and maxHeight don't depend on overlay height) const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight); // Render component at calculated width let overlayLines = this.resolveFakeCursors(component.render(width)); // Apply maxHeight if specified if (maxHeight !== undefined && overlayLines.length > maxHeight) { overlayLines = overlayLines.slice(0, maxHeight); } // Get final row/col with actual overlay height const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight); entry.bounds = { row, col, width, height: overlayLines.length }; rendered.push({ entry, overlayLines, row, col, w: width }); minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);// Pad to at least terminal height so overlays have screen-relative positions.// Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing// inflation that pushed content into scrollback on terminal widen.const workingHeight = Math.max(result.length, termHeight, minLinesNeeded);// Extend result with empty lines if content is too short for overlay placement or working areawhile (result.length < workingHeight) { result.push("");}const viewportStart = Math.max(0, workingHeight - termHeight);// Composite each overlayfor (const { overlayLines, row, col, w } of rendered) { for (let i = 0; i < overlayLines.length; i++) { const idx = viewportStart + row + i; if (idx >= 0 && idx < result.length) { // Defensive: truncate overlay line to declared width before compositing // (components should already respect width, but this ensures it) const truncatedOverlayLine = visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i]; result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth); } }}return result;- Overlay được sắp theo
focusOrder, overlay sau đè overlay trước. Độ rộng được giải trước (không phụ thuộc chiều cao), component render ở đúng độ rộng đó, bị cắt theomaxHeight, rồi mới giải toạ độ với chiều cao thật. Hình chữ nhật cuối cùng được lưu lại chogetBounds()và cho việc dò chuột. - Khung được đệm dòng trống cho đủ ít nhất một màn hình, và toạ độ của overlay tính từ
viewportStart, tức tương đối với màn hình chứ không với đầu tài liệu. Chú thích ở dòng 1421–1423 ghi lại một lỗi cũ: từng dùng mức cao nhấtmaxLinesRenderedđể đệm, và nó tự phình ra, đẩy nội dung vào scrollback mỗi khi terminal rộng ra. - Mỗi dòng được ghép bằng
compositeTuiLine(): tách dòng nền thành phần trước và phần sau theo cột, có giữ trạng thái ANSI, rồi chèn\x1b[0m\x1b]8;;\x07trước và sau đoạn overlay để màu của nền không loang vào hộp thoại và ngược lại. Dòng chứa ảnh thì không ghép (tui.ts:426–455).
Ở chế độ regular, overlay được ghép trước khi so khung, nên với thuật toán vi sai nó chỉ là vài dòng đổi nội dung: hiện ra thì ghi các dòng đó, đóng lại thì các dòng nền khác với khung trước và được ghi lại. Ở fullscreen, overlay được ghép vào màn hình sau bước tô kết quả tìm kiếm và trước bước cắt về height hàng.
Focus
Mỗi lúc có đúng một component giữ focus. setFocus() bật cờ focused của component mới nếu nó là Focusable và tắt cờ của component cũ; cờ này quyết định component có phát CURSOR_MARKER hay không. Overlay giành focus khi hiện ra, trừ khi nonCapturing hoặc đang bị visible() ẩn; khi bị đóng, focus về overlay hiển thị cao nhất còn lại hoặc về preFocus (tui.ts:770–784). Phần phức tạp nhất của TuiBase là một máy trạng thái nhỏ, overlayFocusRestore, với các trạng thái inactive, eligible và blocked (tui.ts:343–353, :625–688). Nó cài đặt một hứa hẹn của README: một overlay đang hiện và đang giữ focus sẽ lấy lại bàn phím sau khi một giao diện thay thế tạm thời trả focus (README.md:226–228). Muốn chủ động nhường bàn phím cho component khác trong lúc overlay vẫn hiện thì gọi handle.unfocus({ target }).
Một container chứa Input hay Editor bên trong phải tự cài Focusable và chuyển cờ focused xuống con. Nếu không, TUI bật cờ trên container, ô nhập bên trong không phát CURSOR_MARKER, và cửa sổ gợi ý của bộ gõ IME hiện sai chỗ (README.md:336–360).
Đường đi của một chuỗi input
Một phím bấm đi qua nhiều tầng trước khi tới component:
- StdinBufferGom các mảnh
datacủa stdin thành từng chuỗi hoàn chỉnh; nội dung dán được gom thành một sự kiện và bọc lại bằng\x1b[200~…\x1b[201~(terminal.ts:265–270). Mục Phân tích phím giải thích vì sao cần. - ProcessTerminalNuốt các câu trả lời thương lượng bàn phím Kitty và trạng thái chương trình; còn lại chuyển cho
TuiBase.handleTerminalInput()(terminal.ts:244–263). - Câu trả lời của terminalCâu trả lời màu (OSC 10, 11, 4 và DA1) khi có truy vấn màu đang chờ, và báo đổi sáng/tối (
\x1b[?997;1nhoặc2n), bị nuốt ởTuiBasetrước mọi thứ khác, không tới component nào. - Input listenerChạy theo thứ tự đăng ký; mỗi listener có thể nuốt chuỗi (
consume) hoặc thay nó (data). Viewport của fullscreen là listener đầu tiên; listener của extension cũng ở đây (xem chương Extensions). - FocusCâu trả lời kích thước ô ảnh (
\x1b[6;h;wt) bị nuốt. Shift+Ctrl+D gọionDebug. Nếu overlay đang giữ focus vừa bị ẩn (resize,visible()) thì chuyển focus; nếu có overlay chờ lấy lại focus thì trả cho nó. - ComponentBỏ sự kiện nhả phím trừ khi component đặt
wantsKeyRelease, gọihandleInput(data), rồi vẽ khung ngay.
private handleTerminalInput(data: string): void { if (this.consumeTerminalColorResponse(data)) { return; } if (this.consumeTerminalColorSchemeReport(data)) { return; } if (this.inputListeners.size > 0) { let current = data; for (const listener of this.inputListeners) { const result = listener(current); if (result?.consume) { return; } if (result?.data !== undefined) { current = result.data; } } if (current.length === 0) { return; } data = current; } // Consume terminal cell size responses without blocking unrelated input. if (this.consumeCellSizeResponse(data)) { return; } // Global debug key handler (Shift+Ctrl+D) if (matchesKey(data, "shift+ctrl+d") && this.onDebug) { this.onDebug(); return; } // Pass input to focused component (including Ctrl+C) // The focused component can decide how to handle Ctrl+C if (this.focusedComponent?.handleInput) { // Filter out key release events unless component opts in if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) { return; } this.focusedComponent.handleInput(data); // Keyboard input is latency-sensitive. Avoid the throttled timer path, // where even setTimeout(0) can take a full 16 ms tick on Windows. this.requestImmediateRender(); }Ctrl+C cũng chỉ là một chuỗi input như mọi phím khác. TUI không xử lý nó; component đang focus quyết định. Trong coding agent, CustomEditor chuyển nó cho hành động app.clear (xoá editor, bấm lần hai thì thoát).
Chuột, chỉ ở fullscreen
TuiMainScreen không bắt chuột, vì terminal sở hữu scrollback và cần chuột để cuộn và chọn chữ (README.md:269). TuiAltScreen chuẩn hoá chuỗi SGR thành TuiMouseEvent có toạ độ cục bộ của component, toạ độ màn hình, kích thước, nút, phím bổ trợ, số lần bấm và delta con lăn (tui.ts:25–44), và component trả lời bằng:
export interface TuiMouseEventResult { /** Stop propagation and suppress renderer-level fallback behavior. */ handled?: boolean; /** Route subsequent drag/release events to this component. Implies handled. */ capture?: boolean; /** Give keyboard focus to this component. Implies handled. */ focus?: boolean; /** * Explicitly request or suppress a render. Move and release default to false; * press, click, drag, and wheel default to true. */ render?: boolean;}handled chặn hành vi mặc định của renderer (cuộn, chọn chữ); capture giữ các sự kiện kéo và nhả tiếp theo cho cùng component; focus xin focus bàn phím; render ghi đè quy tắc mặc định là nhấn, bấm, kéo, con lăn thì vẽ lại còn di chuột và nhả thì không (README.md:290). Để dò trúng con nào, Container.handleMouse() dùng chiều cao từng con ghi lại ở lần render trước, nên di chuột không làm cả cây render lại chỉ để dò (tui.ts:383–403). MouseRegion bọc một component bất kỳ để thêm hành vi chuột mà không đổi cách nó vẽ; coding agent dùng nó cho việc bấm vào khối thinking để ẩn hay hiện (assistant-message.ts:163–170). Từ 1.2.0, con lăn không được component nào xử lý thì cuộn transcript, kể cả khi một overlay đang giữ focus; overlay muốn cuộn bằng con lăn phải cài handleMouse (CHANGELOG.md:35).
Phân tích phím và keybinding#
Vấn đề: terminal mã hoá phím rất mơ hồ
Terminal truyền thống gửi phím dưới dạng byte. Chữ a là 0x61, Ctrl+A là 0x01, Enter là \r, Tab là 0x09 và trùng với Ctrl+I, Esc là 0x1b và trùng với Ctrl+[, Alt+x là ESC rồi x, mũi tên lên là ESC [ A. Chú thích đầu keys.ts nhắc đúng những chỗ trùng này (keys.ts:8–11). Với một coding agent, ba hệ quả gây khó chịu nhất là: Shift+Enter thường gửi y hệt Enter, nên không phân biệt được “xuống dòng” với “gửi prompt”; một byte ESC đơn lẻ có thể là phím Esc hoặc là đầu của một chuỗi chưa tới hết; và một lần đọc stdin có thể chứa vài phím, hoặc chỉ nửa chuỗi.
Thương lượng giao thức: Kitty trước, modifyOtherKeys sau
/** * Query terminal for Kitty keyboard protocol support and enable it if available. * * Kitty's progressive enhancement detection requires requesting the desired * flags before querying them. The trailing DA query is a sentinel supported by * terminals that do not know Kitty keyboard protocol; receiving DA before a * Kitty response enables modifyOtherKeys fallback without a startup timeout. * * The requested flags are: * - 1 = disambiguate escape codes * - 2 = report event types (press/repeat/release) * - 4 = report alternate keys (shifted key, base layout key) * * The OSC 7501 program status query shares the DA sentinel: a terminal that supports it replies * before DA. `PI_PROGRAM_STATUS=1` or `0` skips the query. */private queryAndEnableKittyProtocol(): void { this.setupStdinBuffer(); process.stdin.on("data", this.stdinDataHandler!); this.keyboardProtocolPushed = true; this.pendingKeyboardProtocolDeviceAttributes += 1; this.clearKeyboardProtocolNegotiationBuffer(); const programStatusOverride = process.env.PI_PROGRAM_STATUS; this.programStatusSupported = programStatusOverride === "1"; this.programStatusQueryPending = programStatusOverride !== "1" && programStatusOverride !== "0"; const programStatusQuery = this.programStatusQueryPending ? PROGRAM_STATUS_QUERY : ""; process.stdout.write(`${KITTY_KEYBOARD_PROTOCOL_QUERY}${programStatusQuery}${DEVICE_ATTRIBUTES_QUERY}`); this.writeProgramStatus();}Giao thức bàn phím Kitty cho phép chương trình xin terminal mã hoá phím rõ ràng. \x1b[>7u đẩy cờ 7 = 1 | 2 | 4: phân biệt mã escape, báo loại sự kiện (nhấn, giữ lặp, nhả), báo phím thay thế. \x1b[?u hỏi cờ đang bật. \x1b[c (DA1) là câu hỏi mà mọi terminal đều trả lời, và terminal trả lời theo thứ tự; nên nếu câu trả lời DA1 tới mà chưa thấy câu trả lời Kitty, terminal không biết giao thức Kitty, và không cần một bộ hẹn giờ chờ đoán. Khi nhận được cờ khác 0, ProcessTerminal bật cờ toàn cục setKittyProtocolActive(true) mà bộ khớp phím tra cứu; nếu không, nó bật modifyOtherKeys của xterm bằng \x1b[>4;2m (terminal.ts:308–336, :408–412). Lúc dừng, \x1b[<u gỡ cờ Kitty và \x1b[>4;0m tắt modifyOtherKeys.
function parseKittySequence(data: string): ParsedKittySequence | null { // CSI u format with alternate keys (flag 4): // \x1b[<codepoint>u // \x1b[<codepoint>;<mod>u // \x1b[<codepoint>;<mod>:<event>u // \x1b[<codepoint>:<shifted>;<mod>u // \x1b[<codepoint>:<shifted>:<base>;<mod>u // \x1b[<codepoint>::<base>;<mod>u (no shifted key, only base) // // With flag 2, event type is appended after modifier colon: 1=press, 2=repeat, 3=release // With flag 4, alternate keys are appended after codepoint with colons const csiUMatch = data.match(/^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/); if (csiUMatch) { const codepoint = parseInt(csiUMatch[1]!, 10); const shiftedKey = csiUMatch[2] && csiUMatch[2].length > 0 ? parseInt(csiUMatch[2], 10) : undefined; const baseLayoutKey = csiUMatch[3] ? parseInt(csiUMatch[3], 10) : undefined; const modValue = csiUMatch[4] ? parseInt(csiUMatch[4], 10) : 1; const eventType = parseEventType(csiUMatch[5]); _lastEventType = eventType; return { codepoint, shiftedKey, baseLayoutKey, modifier: modValue - 1, eventType }; }Giá trị phím bổ trợ trên dây lớn hơn bitmask một đơn vị; bitmask là Shift 1, Alt 2, Ctrl 4, Super 8, còn Caps Lock 64 và Num Lock 128 bị bỏ qua khi so (keys.ts:296–303). Tài liệu cấu hình terminal của Pi dùng đúng các chuỗi này khi hướng dẫn người dùng tự map phím:
| Phím | CSI u | Giải mã | Nguồn |
|---|---|---|---|
| Shift+Enter | \x1b[13;2u | 13 = Enter, 2 − 1 = Shift | terminal-setup.md:177 |
| Alt+Enter | \x1b[13;3u | 3 − 1 = Alt | :88 |
| Ctrl+- | \x1b[45;5u | 45 = -, 5 − 1 = Ctrl | :161 |
| Ctrl+Alt+] | \x1b[93;7u | 93 = ], 7 − 1 = Ctrl + Alt | :162 |
modifyOtherKeys mã hoá cùng thông tin theo dạng CSI 27 ; mod ; code ~, ví dụ \x1b[27;2;13~ cho Shift+Enter (keys.ts:722–728). Đây là dạng mà tmux 3.2 tới 3.4 chuyển tiếp; tmux từ 3.5 chuyển tiếp được CSI u nếu bật extended-keys-format csi-u (tmux.md:13–22).
StdinBuffer: cắt luồng byte thành từng chuỗi
Một sự kiện data của stdin không phải một phím. Chú thích của StdinBuffer đưa ví dụ chuỗi chuột \x1b[<35;20;5m tới thành ba mảnh \x1b, [<35, ;20;5m (stdin-buffer.ts:1–18). Bộ đệm nhận biết điểm kết thúc của từng loại chuỗi (CSI kết thúc bằng một byte trong khoảng 0x40–0x7e, OSC bằng BEL hoặc ESC \, DCS và APC bằng ESC \), phát từng chuỗi hoàn chỉnh, và giữ phần dở dang lại, xả nó ra như phím sau một thời gian chờ có hạn (stdin-buffer.ts:411–432).
Một ESC đơn lẻ chờ escapeTimeout, mặc định 10 ms; qua SSH (có SSH_CONNECTION hoặc SSH_TTY) là 100 ms, vì Alt+phím kiểu cũ là ESC cộng một byte và trên đường truyền chậm hai byte đó có thể tới cách xa nhau; biến PI_TUI_ESC_TIMEOUT ghi đè cả hai (terminal.ts:142–151). Chuỗi dở khác chờ 50 ms. Riêng câu trả lời của terminal có phần đầu nhận ra được (màu, trạng thái chương trình, XTVersion, ảnh Kitty) thì chờ tới khi có ký tự kết thúc, không xả theo thời gian (dòng 416). Lý do được ghi ngay trong chú thích: xả ra thì phần đuôi bị gõ vào prompt như chữ thường, và ký tự BEL kết thúc của OSC thành Ctrl+G, phím mở trình soạn thảo ngoài. Đó là lỗi đã sửa ở 1.2.0 (CHANGELOG.md:36). Bộ đệm cũng gom nội dung dán giữa \x1b[200~ và \x1b[201~ thành một sự kiện paste, nên một lần dán dài không bị phân tích thành hàng nghìn phím.
matchesKey(): một tên phím, nhiều cách mã hoá
Tên phím có kiểu KeyId, một hợp của các template literal như "ctrl+shift+p", nên viết sai tên là lỗi biên dịch (keys.ts:141–152). matchesKey(data, keyId) chấp nhận mọi cách mà một terminal có thể đã gửi phím đó. Trường hợp Shift+Enter cho thấy rõ nhất:
case "enter":case "return": if (modifier === MODIFIERS.shift) { // CSI u sequences (standard Kitty protocol) if ( matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) || matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.shift) ) { return true; } // xterm modifyOtherKeys format (fallback when Kitty protocol not enabled) if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.shift)) { return true; } // When Kitty protocol is active, legacy sequences are custom terminal mappings // \x1b\r = Kitty's "map shift+enter send_text all \e\r" // \n = Ghostty's "keybind = shift+enter=text:\n" if (_kittyProtocolActive) { return data === "\x1b\r" || data === "\n"; } return false; }Hai chuỗi ở dòng 935 là map người dùng hay tự đặt trong terminal (chú thích ghi map shift+enter send_text all \e\r của Kitty và keybind = shift+enter=text:\n của Ghostty). Chúng chỉ được hiểu là Shift+Enter khi giao thức Kitty đang bật; nếu không, \x1b\r là Alt+Enter kiểu cũ và \n là Ctrl+J. Tài liệu khuyên gỡ map Ghostty kiểu này vì Pi không phân biệt nó với Ctrl+J (terminal-setup.md:57–63). Vài chi tiết khác của bộ khớp:
- Bố cục bàn phím không phải Latin. Với tiếng Nga, Ctrl+С (chữ Cyrillic) vẫn khớp
ctrl+cnhờ “phím theo bố cục gốc” mà Kitty báo kèm. Nhưng bộ khớp chỉ dùng phím gốc khi mã phím không phải chữ Latin hay ký hiệu đã biết, để bố cục Dvorak hay Colemak không khớp nhầm Ctrl+K thành Ctrl+V (keys.ts:700–717). - Nhả phím.
isKeyRelease()nhận ra sự kiện nhả bằng hậu tố:3u,:3~,:3A... và bỏ qua nội dung dán, vì một địa chỉ MAC như90:62:3Fchứa đúng chuỗi đó (keys.ts:542–566). - Gõ chữ. Khi bật cờ phân biệt, terminal có thể gửi cả chữ thường dưới dạng CSI u. Ô nhập dùng
decodePrintableKey()để lấy lại ký tự, và chỉ nhận chữ không kèm Ctrl hay Alt (keys.ts:1404–1437). - Backspace mơ hồ. Byte
0x08là Ctrl+Backspace trên Windows Terminal nhưng là Backspace thường ở vài terminal và cấu hình tmux; bộ khớp đoán theoWT_SESSION(keys.ts:747–760).
Keybinding: gọi phím bằng tên hành động
Component không nên viết cứng matchesKey(data, "ctrl+k"); nó hỏi theo tên hành động, và người dùng đổi được phím cho hành động đó. Danh sách hành động là một interface mở rộng được bằng declaration merging:
/** * Global keybinding registry. * Downstream packages can add keybindings via declaration merging. */export interface Keybindings { // Editor navigation and editing "tui.editor.cursorUp": true; "tui.editor.cursorDown": true; "tui.editor.historyPrevious": true; "tui.editor.historyNext": true; "tui.altScreen.top": true; "tui.altScreen.bottom": true;}export type Keybinding = keyof Keybindings;export interface KeybindingDefinition { defaultKeys: KeyId | KeyId[]; description?: string;}export type KeybindingDefinitions = Record<string, KeybindingDefinition>;export type KeybindingsConfig = Record<string, KeyId | KeyId[] | undefined>;pi-tui định nghĩa các hành động tui.editor.*, tui.input.*, tui.select.*, tui.altScreen.* cùng phím mặc định trong TUI_KEYBINDINGS (keybindings.ts:71–210). KeybindingsManager.rebuild() dựng một bảng tra từ tên hành động tới danh sách phím, và matches(data, action) chỉ là thử matchesKey() với từng phím trong danh sách (keybindings.ts:243–276).
Cấu hình người dùng thay thế danh sách phím của một hành động chứ không cộng thêm, và một mảng rỗng tắt hành động đó (keybindings.md:21–27). Xung đột chỉ được ghi nhận giữa các phím do người dùng gán, không giữa phím người dùng với phím mặc định. Bảng tra là một biến toàn cục (setKeybindings/getKeybindings, keybindings.ts:309–320), nên editor và viewport của fullscreen đọc chung một bản mà không phải truyền qua constructor. Coding agent mở rộng sổ đăng ký với các hành động app.* và đổi vài mặc định theo nền tảng:
declare module "@earendil-works/pi-tui" { interface Keybindings extends AppKeybindings {}}export const KEYBINDINGS = { ...TUI_KEYBINDINGS, "tui.editor.undo": { ...TUI_KEYBINDINGS["tui.editor.undo"], defaultKeys: process.platform === "win32" ? "ctrl+z" : windowsKeybindings ? "alt+z" : "ctrl+-", }, "tui.altScreen.previousPrompt": { ...TUI_KEYBINDINGS["tui.altScreen.previousPrompt"], defaultKeys: windowsKeybindings ? "ctrl+up" : ["ctrl+shift+up", "ctrl+up"], }, "tui.altScreen.nextPrompt": { ...TUI_KEYBINDINGS["tui.altScreen.nextPrompt"], defaultKeys: windowsKeybindings ? "ctrl+down" : ["ctrl+shift+down", "ctrl+down"], }, "tui.altScreen.search": { ...TUI_KEYBINDINGS["tui.altScreen.search"], defaultKeys: windowsKeybindings ? "ctrl+f" : "ctrl+shift+f", }, ...APP_KEYBINDINGS,} as const satisfies KeybindingDefinitions;Trên Windows và WSL, tìm kiếm trong transcript mặc định là Ctrl+F. Undo thì tuỳ: Windows gốc không có job control nên Ctrl+Z rảnh và được dùng cho undo (keybindings.md:131); WSL vẫn dùng Ctrl+Z để tạm dừng tiến trình nên undo chuyển sang Alt+Z; các nền tảng khác dùng Ctrl+-. KeybindingsManager.create() của coding agent đọc <agent-dir>/keybindings.json, mặc định ~/.pi/agent/keybindings.json (keybindings.ts:337–341), và interactive mode cài nó làm bảng toàn cục ngay trong constructor (interactive-mode.ts:646–647).
Editor: soạn nhiều dòng, autocomplete, paste, undo#
Editor (components/editor.ts, 2.473 dòng) là component lớn nhất của gói, và là nơi người dùng ở lâu nhất. State cốt lõi rất nhỏ:
interface EditorState { lines: string[]; cursorLine: number; cursorCol: number;}/** Undo snapshot: editor text state plus the paste registry. */interface EditorSnapshot { state: EditorState; pastes: Map<number, string>; pasteCounter: number;}Văn bản là một mảng dòng logic, con trỏ là cặp (dòng, cột) với cột là chỉ số trong chuỗi của dòng đó. Ngắt dòng không nằm trong state: mỗi lần render, layoutText() ngắt các dòng logic thành dòng hiển thị theo độ rộng hiện tại (editor.ts:998). Di chuyển lên xuống đi theo dòng hiển thị và nhớ một “cột ưa thích” (preferredVisualCol) để con trỏ không trôi dần sang trái khi đi qua các dòng ngắn. Ngoài ra editor giữ bảng nội dung dán, lịch sử prompt (tối đa 100 mục), kill ring kiểu Emacs, ngăn xếp undo và trạng thái autocomplete (editor.ts:337–372).
render(): chiều cao có trần, cuộn bên trong
// Calculate max visible lines: 30% of terminal height, minimum 5 linesconst terminalRows = this.tui.terminal.rows;const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3));// Find the cursor line index in layoutLineslet cursorLineIndex = layoutLines.findIndex((line) => line.hasCursor);if (cursorLineIndex === -1) cursorLineIndex = 0;// Adjust scroll offset to keep cursor visibleif (cursorLineIndex < this.scrollOffset) { this.scrollOffset = cursorLineIndex;} else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) { this.scrollOffset = cursorLineIndex - maxVisibleLines + 1;}// Clamp scroll offset to valid rangeconst maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines);this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset));// Get visible lines sliceconst visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);this.renderedVisibleLineCount = visibleLines.length;Editor đọc tui.terminal.rows, đó là lý do constructor bắt buộc nhận tui. Trần chiều cao có từ khi một prompt dài hơn màn hình làm hỏng hiển thị của terminal (CHANGELOG.md:1068). Phần còn lại của render() vẽ viền trên và viền dưới (kèm “↑ N more” khi có phần bị che), chèn CURSOR_MARKER và con trỏ giả vào đúng grapheme dưới con trỏ, đệm mỗi dòng đủ độ rộng, và nối danh sách autocomplete bên dưới viền dưới (editor.ts:559–630). Khi không có padding ngang, độ rộng bố trí chừa một cột cho con trỏ đứng cuối dòng (dòng 526–528).
handleInput(): thứ tự ưu tiên
Một chuỗi input được thử lần lượt (editor.ts:696–996), và cách đầu tiên khớp thắng:
- Chế độ nhảySau Ctrl+], phím in được tiếp theo là ký tự đích để nhảy tới.
- Bracketed pasteTừ
\x1b[200~, mọi thứ được gom cho tới\x1b[201~rồi chuyển chohandlePaste(); phần sau dấu đóng được xử lý tiếp như input thường. - Ctrl+C và undo
tui.input.copytrả về ngay để component cha quyết định;tui.editor.undohoàn tác. - Autocomplete đang mởEsc huỷ, lên xuống chọn, Tab áp dụng, Enter áp dụng; với lệnh slash, Enter áp dụng rồi đi tiếp xuống bước gửi.
- Soạn thảoTab gọi completion; các phím xoá; yank và yank-pop của kill ring; lịch sử; di chuyển con trỏ.
- Xuống dòng hay gửi
tui.input.newLine(Shift+Enter, Ctrl+J) chèn dòng. Enter gửi, trừ khi ký tự ngay trước con trỏ là\: khi đó dấu\bị xoá và một dòng mới được chèn, một cách lách cho terminal không gửi được Shift+Enter (dòng 912–919). - Lên, xuốngỞ dòng hiển thị đầu tiên, phím lên duyệt lịch sử nếu editor rỗng, đang duyệt dở, hoặc con trỏ ở cột 0; nếu không thì nhảy về đầu dòng (dòng 926–939).
- Ký tựThử
decodePrintableKey()cho chữ đến dưới dạng CSI u, rồi tới byte thường từ0x20trở lên.
Dán: marker thay cho cả khối
// Split into lines to check for large pasteconst pastedLines = filteredText.split("\n");// Check if this is a large paste (> 10 lines or > 1000 characters)const totalChars = filteredText.length;if (pastedLines.length > 10 || totalChars > 1000) { // Store the paste and insert a marker this.pasteCounter++; const pasteId = this.pasteCounter; this.pastes.set(pasteId, filteredText); // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]" const marker = pastedLines.length > 10 ? `[paste #${pasteId} +${pastedLines.length} lines]` : `[paste #${pasteId} ${totalChars} chars]`; this.insertTextAtCursorInternal(marker); return;}Trước đó, handlePaste() chuẩn hoá xuống dòng, đổi tab thành bốn dấu cách, lọc ký tự điều khiển, và giải mã các Ctrl+chữ mà vài terminal (ví dụ popup của tmux với extended-keys-format=csi-u) mã hoá lại thành CSI u bên trong nội dung dán (editor.ts:1267–1277). Một đường dẫn dán dính vào sau một chữ được thêm dấu cách phía trước. Nội dung lớn được cất vào bảng pastes, còn editor chỉ chứa marker như [paste #1 +123 lines]. segmentWithMarkers() gộp mọi grapheme của một marker hợp lệ thành một đơn vị, nên di chuyển, xoá và ngắt dòng coi marker như một ký tự (editor.ts:41–105). Marker chỉ được thay lại bằng nội dung thật ở hai chỗ. Khi gửi, submitValue() mở mọi marker, xoá state, bảng nội dung dán và ngăn xếp undo, rồi gọi onSubmit (editor.ts:1364–1378). Khi mở trình soạn thảo ngoài, getExpandedText() đưa ra văn bản đầy đủ (editor.ts:1099–1105).
README chỉ nói về ngưỡng “hơn 10 dòng” (README.md:467); code còn tạo marker cho một lần dán một dòng dài hơn 1.000 ký tự, với dạng [paste #2 1234 chars].
Undo kiểu fish
private insertCharacter(char: string, skipUndoCoalescing?: boolean): void { this.exitHistoryBrowsing(); // Undo coalescing (fish-style): // - Consecutive word chars coalesce into one undo unit // - Space captures state before itself (so undo removes space+following word together) // - Each space is separately undoable // Skip coalescing when called from atomic operations (e.g., handlePaste) if (!skipUndoCoalescing) { if (isWhitespaceChar(char) || this.lastAction !== "type-word") { this.pushUndoSnapshot(); } this.lastAction = "type-word"; }private pushUndoSnapshot(): void { this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter });}private undo(): void { this.exitHistoryBrowsing(); const snapshot = this.undoStack.pop(); if (!snapshot) return; Object.assign(this.state, snapshot.state); this.pastes = snapshot.pastes; this.pasteCounter = snapshot.pasteCounter; this.lastAction = null; this.preferredVisualCol = null; if (this.onChange) { this.onChange(this.getText()); }}Gõ liên tiếp các ký tự trong một từ chỉ tạo một snapshot ở đầu từ; mỗi khoảng trắng tạo snapshot ngay trước nó. Undo vì vậy xoá từng từ chứ không từng ký tự, giống shell fish như chú thích ghi. UndoStack.push() lưu một bản sao sâu bằng structuredClone, nên snapshot không bị sửa ngầm khi state đổi sau đó (undo-stack.ts:1–13). Snapshot gồm cả bảng pastes và bộ đếm, nên undo một lần dán xoá luôn nội dung đã cất. Kill ring là một vòng chuỗi riêng: các lần xoá liên tiếp được nối vào cùng một mục, Ctrl+Y dán mục mới nhất, Alt+Y xoay vòng (kill-ring.ts:1–28).
Autocomplete bất đồng bộ
export interface AutocompleteProvider { /** Characters that should naturally trigger this provider at token boundaries. */ triggerCharacters?: string[]; // Get autocomplete suggestions for current text/cursor position // Returns null if no suggestions available getSuggestions( lines: string[], cursorLine: number, cursorCol: number, options: { signal: AbortSignal; force?: boolean }, ): Promise<AutocompleteSuggestions | null>; // Apply the selected item // Returns the new text and cursor position applyCompletion( lines: string[], cursorLine: number, cursorCol: number, item: AutocompleteItem, prefix: string, ): { lines: string[]; cursorLine: number; cursorCol: number; }; // Check if file completion should trigger for explicit Tab completion shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;}Editor tự gọi gợi ý khi gõ / ở đầu tin nhắn (lệnh slash), khi gõ @, # hoặc ký tự kích hoạt riêng của provider ở đầu một token, và khi gõ tiếp chữ trong một ngữ cảnh như vậy; ngoài ngữ cảnh lệnh slash, Tab ép gợi ý đường dẫn file (editor.ts:1228–1257). Vì getSuggestions() có thể chậm (tìm file qua fd, gọi completion của extension), editor xếp các yêu cầu nối tiếp nhau, huỷ yêu cầu cũ bằng AbortController, hoãn 20 ms với các token bắt đầu bằng ký tự kích hoạt như @ hay #, và bỏ kết quả tới muộn:
private isAutocompleteRequestCurrent( requestId: number, controller: AbortController, snapshotText: string, snapshotLine: number, snapshotCol: number,): boolean { return ( !controller.signal.aborted && requestId === this.autocompleteRequestId && this.getText() === snapshotText && this.state.cursorLine === snapshotLine && this.state.cursorCol === snapshotCol );}Khi Tab ép gợi ý và chỉ có đúng một kết quả, nó được áp dụng luôn mà không mở danh sách (editor.ts:2393–2410). CombinedAutocompleteProvider của pi-tui ghép lệnh slash với đường dẫn file; với tiền tố @, nó tìm mờ qua fd, có theo .gitignore, gồm file ẩn, bỏ thư mục .git (autocomplete.ts:145–175). Coding agent dựng provider từ lệnh có sẵn, prompt template, lệnh của extension và skill, rồi cho extension bọc thêm (interactive-mode.ts:827–843).
CustomEditor: phím của ứng dụng đứng trước
Coding agent không sửa Editor; nó kế thừa. CustomEditor.handleInput() (custom-editor.ts:88–147) thử phím tắt do extension đăng ký, phím dán ảnh, Esc (chỉ ngắt agent khi danh sách autocomplete không mở, nếu không thì để Esc đóng danh sách), Ctrl+D (chỉ thoát khi editor rỗng), rồi mọi hành động app.* đã đăng ký, cuối cùng mới tới super.handleInput(). Nó cũng vẽ chỉ báo “working” ngay trong viền trên (custom-editor.ts:36–79), lý do status container trống trong trace.
Markdown và hình ảnh#
Markdown
Câu trả lời của mô hình là Markdown, nên Markdown là component được render nhiều nhất. Nó dùng lexer của marked với ba phần tuỳ biến: gạch ngang chỉ nhận ~~ chặt chẽ, nhấn mạnh thân thiện với chữ CJK (marked-cjk-friendly), và hai extension LaTeX cho công thức inline và dạng khối, được vẽ thành ký tự Unicode bởi latex.ts (markdown.ts:190–197). Mỗi token thành các dòng có mã ANSI theo MarkdownTheme, một tập hàm tô màu do ứng dụng cung cấp, kể cả highlightCode cho khối code (markdown.ts:222–240). Hai lớp cache:
render(width: number): string[] { // Check cache if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) { return this.cachedLines; } // Calculate available width for content (subtract horizontal padding) const contentWidth = Math.max(1, width - this.paddingX * 2); const text = this.options.transform?.(this.text, contentWidth) ?? this.text; // Don't render anything if there's no actual text if (!text || text.trim() === "") { const result: string[] = []; // Update cache this.cachedText = this.text; this.cachedWidth = width; this.cachedLines = result; return result; } // Replace tabs with 3 spaces for consistent rendering const normalizedText = text.replace(/\t/g, " "); // Parse markdown to HTML-like tokens const cached = this.cachedTokens?.deref(); let tokens = cached?.source === normalizedText ? cached.tokens : undefined; if (!tokens) { tokens = markdownParser.lexer(normalizedText); trimPartialClosingFences(tokens); this.cachedTokens = new WeakRef({ source: normalizedText, tokens }); }Cache dòng trúng khi văn bản và độ rộng không đổi, tức là với mọi tin nhắn đã xong. Cache token chỉ phụ thuộc mã nguồn Markdown, nên sống qua được việc đổi theme hay đổi độ rộng; nó được giữ bằng WeakRef vì theo chú thích, cây token lớn khoảng mười lần mã nguồn và mỗi tin nhắn của một transcript dài đều giữ một Markdown (markdown.ts:271–274). Cuối render(), flattenLines() ép V8 làm phẳng các chuỗi được ghép từ nhiều mảnh, vốn giữ cây các mảnh con và tốn gấp mấy lần kích thước thật (utils.ts:891–898). Hai thay đổi này của 1.0.0 làm một tin nhắn dài chỉ còn giữ khoảng một phần năm bộ nhớ so với trước (CHANGELOG.md:86).
Tin nhắn đang stream thì khác. AssistantMessageComponent.updateContent() tạo một Markdown mới mỗi lần cập nhật, nên cả hai cache của nó không giúp gì: mỗi khung, đoạn văn đang stream được lex và ngắt dòng lại từ đầu. Chi phí đó tỷ lệ với độ dài của một tin nhắn, không phải của cả transcript, và bị chặn bởi nhịp 16 ms. Vài chỗ trong Markdown có mặt chỉ vì streaming: trimPartialClosingFences() bỏ dấu đóng khối code đang tới dở (dòng cuối chỉ gồm vài dấu backtick hay dấu ngã, ngắn hơn dấu mở), để khối code không co lại rồi giãn ra khi ký tự cuối tới (markdown.ts:165–188); công thức $$ chưa đóng được giữ ở dạng thô cho tới khi đóng, và từ 1.4.0 không còn nuốt bảng phía sau (CHANGELOG.md:11).
Ảnh inline: Kitty và iTerm2
Terminal có hai giao thức ảnh phổ biến. pi-tui đoán terminal hỗ trợ cái nào từ biến môi trường: Kitty, Ghostty, WezTerm, Warp, Herdr, Rex dùng giao thức Kitty; iTerm2 dùng giao thức riêng; dưới tmux hay screen thì tắt ảnh vì không đáng tin (terminal-image.ts:70–146). Người dùng ghi đè được bằng PI_IMAGE_PROTOCOL, PI_TRUE_COLOR, PI_HYPERLINKS hoặc setting terminal.* (terminal-setup.md:207–219).
Giao thức Kitty truyền ảnh PNG đã mã hoá base64 trong các chuỗi APC \x1b_G…;…\x1b\\, chia khúc 4.096 ký tự với cờ m=1 cho khúc còn tiếp; tham số a=T,f=100,q=2 nghĩa là truyền và hiện ngay, định dạng PNG, không trả lời; c/r là số cột và hàng ô chữ ảnh chiếm, i là id ảnh, C=1 bảo terminal không dời con trỏ (terminal-image.ts:232–276). Ảnh JPEG, GIF, WebP cần một hàm chuyển sang PNG do ứng dụng đăng ký; thiếu nó thì hiện chữ thay thế (image.ts:24–31). iTerm2 dùng OSC 1337 với inline=1 và kích thước byte (terminal-image.ts:299–325). Điều quan trọng với TUI là ảnh phải khớp vào mô hình dòng:
if (caps.images && data) { if (caps.images === "kitty" && this.imageId === undefined) { this.imageId = allocateImageId(); } const result = renderImage(data, dimensions, { maxWidthCells: maxWidth, maxHeightCells: maxHeight, imageId: this.imageId, moveCursor: false, }); if (result) { // Store the image ID for later cleanup if (result.imageId) { this.imageId = result.imageId; } if (caps.images === "kitty") { // For Kitty: C=1 prevents cursor movement. // Don't need the cursor movement. lines = [result.sequence]; // Return `rows` lines so TUI accounts for image height. for (let i = 0; i < result.rows - 1; i++) { lines.push(""); } } else { // Return `rows` lines so TUI accounts for image height. // First (rows-1) lines are empty and cleared before the image is drawn. // Last line: move cursor back up, draw the image, then move back down // so TUI cursor accounting stays inside the scroll area. lines = []; for (let i = 0; i < result.rows - 1; i++) { lines.push(""); } const rowOffset = result.rows - 1; const moveUp = rowOffset > 0 ? `\x1b[${rowOffset}A` : ""; lines.push(moveUp + result.sequence); }Ảnh cao N hàng ô chữ trả về đúng N dòng, nên phép đếm dòng của cả hai renderer vẫn đúng. Với Kitty, chuỗi ảnh nằm ở dòng đầu và N − 1 dòng rỗng giữ chỗ. Với iTerm2, N − 1 dòng đầu rỗng, còn dòng cuối là lệnh lên N − 1 dòng rồi tới chuỗi ảnh; theo chú thích trong code, việc vẽ ảnh đưa con trỏ trở xuống, nên phép đếm dòng của TUI vẫn nằm trong vùng cuộn. Kích thước ô theo pixel, cần để tính N, được hỏi bằng CSI 16 t lúc khởi động; khi có câu trả lời, TUI gọi invalidate() cho cả cây để ảnh tính lại (tui.ts:1217–1235).
Dòng ảnh không bị nối reset và không bị ghép overlay. TuiMainScreen mở rộng vùng thay đổi để bao trọn các hàng một ảnh chiếm, xoá ảnh cũ theo id bằng a=d,d=I trước khi vẽ, và vẽ lại toàn bộ nếu việc chừa hàng cho ảnh sẽ làm màn hình cuộn (tui-main-screen.ts:210–245). TuiAltScreen còn làm thêm hai việc. Ảnh đã tải lên terminal được dùng lại bằng lệnh đặt chỗ a=p thay vì truyền lại cả dữ liệu, với giới hạn 16 ảnh, 32 MiB dữ liệu truyền và 64 MiB ước tính sau giải mã cho các ảnh đang khuất (tui-alt-screen.ts:445–493). Và ảnh bị cuộn khuất một phần được cắt bằng tham số y, h, r để chỉ hiện phần nằm trong viewport (terminal-image.ts:464–476).
Phần native#
Phần lớn những gì TUI cần đều đi qua terminal bằng chuỗi điều khiển. Ba thứ thì không: đọc clipboard của hệ điều hành (văn bản, ảnh, danh sách file, cho phím dán ảnh), biết phím bổ trợ nào đang được giữ ngay lúc này, và bật chế độ nhập VT của console Windows. packages/tui/native/ chứa ba addon nhỏ viết bằng C và Objective-C cho đúng ba việc đó, kèm bản dựng sẵn cho x64 và arm64:
| Thư mục | Hàm xuất ra | Dựa trên |
|---|---|---|
native/darwin/ | isModifierPressed, getText, setText, getImage, getFilePaths (darwin-platform.m:126–130) | AppKit; trạng thái phím qua CGEventSourceFlagsState |
native/win32/ | enableVirtualTerminalInput, isModifierPressed, getText, setText, getImage (win32-platform.c:235–239) | kernel32, user32; GetAsyncKeyState, SetConsoleMode |
native/linux/ | getText, getImage (linux-platform-x11.c:271–272) | X11 qua libxcb.so.1; Wayland dùng wl-paste ở phía coding agent |
export interface NativeClipboard { /** Undefined means unavailable, null means no text; transfer failures reject. */ getText(): Promise<string | null | undefined>; /** Undefined means unavailable, null means no image; transfer failures reject. */ getImage(): Promise<Uint8Array | null | undefined>; /** POSIX paths of file URLs on the clipboard. Undefined means unsupported, null means no files. */ getFilePaths?(): Promise<string[] | null | undefined>; /** Linux uses command-line tools to retain clipboard ownership instead. */ setText?(text: string): Promise<void>;}type NativePlatformHelper = NativeClipboard & { enableVirtualTerminalInput?: () => boolean; isModifierPressed?: (name: ModifierKey) => boolean;};export function getNativePlatformHelper(): NativePlatformHelper | undefined { if (process.platform !== "darwin" && process.platform !== "win32") return undefined; return loadNativePlatformHelper(process.platform);}/** Load a clipboard helper without opening the display until a read is requested. */export function getNativeClipboard(): NativeClipboard | undefined { if (process.platform !== "linux") return getNativePlatformHelper(); if (!process.env.DISPLAY) return undefined; return loadNativePlatformHelper("linux", "-x11");}Mỗi hàm clipboard trả về một Promise và phân biệt ba trường hợp: undefined nghĩa là không dùng được, null nghĩa là clipboard không có loại dữ liệu đó, còn lỗi truyền dữ liệu thì reject. Trên Linux, helper chỉ được nạp khi có DISPLAY, và theo chú thích ở dòng 25 chỉ việc nạp module được cache, không phải tình trạng của màn hình, vì một display bị ngắt có thể kết nối lại. Addon không cần header của Node: napi.h tự tìm một tập nhỏ hàm N-API lúc chạy, “from Node or Bun, without Node headers or a link-time dependency on a particular runtime” (napi.h:4–5). File .node được tìm ở vài chỗ: cạnh gói đã cài, cạnh module đang chạy, và cạnh file thực thi, để bản binary đóng gói một file cũng tìm thấy (native-module-path.ts:14–31). Không tìm thấy thì hàm trả về undefined và người gọi đi đường lui; coding agent khi đó đọc clipboard bằng các lệnh như wl-paste hay xclip (clipboard-image.ts:173).
Trạng thái phím bổ trợ giải quyết một vấn đề cụ thể của bàn phím: Terminal.app của macOS và console Windows có thể gửi Enter trần (\r) cho cả Shift+Enter. Khi nhận đúng một \r trên hai nền tảng đó, ProcessTerminal hỏi helper xem Shift có đang được giữ không, và nếu có thì đổi chuỗi thành Shift+Enter dạng CSI u:
private forwardInputSequence(sequence: string): void { if (!this.inputHandler) return; const shouldDetectNativeShiftEnter = sequence === "\r" && (isAppleTerminalSession() || process.platform === "win32"); const input = normalizeNativeShiftEnterInput( sequence, shouldDetectNativeShiftEnter, shouldDetectNativeShiftEnter && isNativeModifierPressed("shift"), ); this.inputHandler(input);}Cách này chỉ đúng khi Pi chạy trên cùng máy với terminal; qua SSH, máy chạy Pi không nhìn thấy bàn phím của người dùng (terminal-setup.md:43–45). Việc thứ ba, enableVirtualTerminalInput, thêm cờ ENABLE_VIRTUAL_TERMINAL_INPUT cho stdin của console (win32-platform.c:33–48). Nếu thiếu cờ đó, theo chú thích trong terminal.ts, libuv đọc sự kiện console thô và bỏ mất phím bổ trợ, nên Shift+Tab tới như Tab thường; cờ phải được đặt sau setRawMode(true) vì raw mode đặt lại các cờ console (terminal.ts:221–225).
Interactive mode dựng giao diện trên pi-tui#
Phần này nhìn từ phía ứng dụng: coding agent chọn renderer, dựng cây component và nối sự kiện của session vào cây ra sao. Vòng đời của bản thân InteractiveMode (khởi động, lệnh slash, chuyển session) thuộc chương Các chế độ chạy.
Chọn renderer
export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScreen | TuiAltScreen { const terminal = options.terminal ?? new ProcessTerminal(); if (options.tuiMode === "fullscreen") { const styleSearchMatch = (text: string) => theme.bg("searchMatchBg", theme.fg("searchMatchText", text)); return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, { searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)), searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))), searchNavigationButtonStyle: (text, hovered) => (hovered ? theme.underline(text) : text), scrollToEndIndicator: () => { const shortcut = keyDisplayText("tui.altScreen.bottom"); const label = ` ↓ Jump to latest message${shortcut ? ` · ${shortcut}` : ""} `; return theme.bg("selectedBg", theme.fg("text", label)); }, openUrl: openBrowser, onRightClickPaste: options.onRightClickPaste, copyOnSelect: options.fullscreenCopyOnSelect, wheelScrollLines: options.fullscreenWheelScrollLines ?? "auto", copySelection: async (text) => { try { await copyToClipboard(text); return true; } catch (error) { return error instanceof Error ? error.message : String(error); } }, }); } return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory);}Đây là chỗ duy nhất biết đến cả hai lớp renderer. Ở fullscreen, nó nối các điểm mở của TuiAltScreen vào thế giới của coding agent: kiểu tô kết quả tìm kiếm lấy từ theme, nhãn “↓ Jump to latest message” ghi kèm phím tắt hiện hành, mở URL bằng trình duyệt, copy bằng clipboard thật thay cho OSC 52, và con lăn mặc định ở chế độ "auto" (tăng tốc khi quay nhanh trên các terminal không tự tăng tốc). Phần còn lại của interactive mode chỉ thấy interface TUI.
Vì người dùng có thể đổi chế độ trong /settings lúc đang chạy, renderer có thể bị thay. Component nào đã giữ tham chiếu tới renderer cũ (editor, loader, tool row) sẽ gọi vào một đối tượng đã dừng. createInteractiveTuiReference() giải quyết việc đó bằng một Proxy: mỗi lần đọc thuộc tính hay gọi phương thức, nó hỏi renderer hiện tại qua getTui() (tui-renderer.ts:52–81). InteractiveMode giữ renderer thật và đưa cho mọi người ui là proxy này (interactive-mode.ts:624–633).
Một cây, hai cách gắn
// Keep one component tree and remount it when changing renderers.this.renderWidgets(); // Initialize with default spacerconst viewport = createChatViewport({ document: this.documentContainer, pendingMessages: this.pendingMessagesContainer, status: this.statusContainer, widgetsAbove: this.widgetContainerAbove, editor: this.editorContainer, widgetsBelow: this.widgetContainerBelow, footer: this.footerContainer, scrollbar: this.settingsManager.getFullscreenScrollbar(), scrollbarTrackStyle: (text) => theme.fg("scrollbarTrack", text), scrollbarThumbStyle: (text) => theme.fg("scrollbarThumb", text),});this.transcriptScrollView = viewport.transcript;this.fullscreenLayoutRoot = viewport.root;this.mountInteractiveTui(this.renderer, [ this.documentContainer, this.pendingMessagesContainer, this.statusContainer, this.widgetContainerAbove, this.editorContainer, this.widgetContainerBelow, this.footerContainer,]);TUI.children (regular: nối dọc) layoutRoot (fullscreen: createChatViewport) ├─ documentContainer VStack │ ├─ headerContainer ├─ ScrollView(documentContainer) grow 1, primary, follow end │ ├─ loadedResourcesContainer └─ VStack dock basis auto │ └─ chatContainer ├─ pendingMessagesContainer │ ├─ UserMessageComponent ├─ statusContainer │ ├─ AssistantMessageComponent ├─ widgetContainerAbove │ ├─ ToolExecutionComponent ... ├─ editorContainer ─ CustomEditor ├─ pendingMessagesContainer ├─ widgetContainerBelow ├─ statusContainer └─ footerContainer ─ FooterComponent ├─ widgetContainerAbove ├─ editorContainer ─ CustomEditor ├─ widgetContainerBelow └─ footerContainer ─ FooterComponent
children là bố cục. Ở fullscreen, setLayoutRoot() bọc chúng trong layout có chiều cao ràng buộc, và render() của TUI đi qua layout root (tui-alt-screen.ts:334–336). children vẫn được gắn ở cả hai chế độ: đó là danh sách mà switchTuiMode() mang sang renderer mới.Container là đơn vị thay thế: bộ chọn model, session hay settings không phải overlay mà được đặt thay editor trong editorContainer, nhận focus, và trả editor về chỗ khi xong (interactive-mode.ts:4853–4876). Widget của extension vào widgetContainerAbove/Below; khi không có widget nào, container phía trên chứa một Spacer(1) (:2498–2511).
Đổi chế độ khi đang chạy
private switchTuiMode(mode: TuiMode, restoreProgress = true, startRenderer = true): boolean { const previousUi = this.renderer; if (mode === previousUi.mode) return true; if (previousUi.hasOverlayEntries) return false; const components = [...previousUi.children]; const focus = previousUi.getFocusedComponent(); const terminal = previousUi.terminal; const showHardwareCursor = previousUi.getShowHardwareCursor(); const clearOnShrink = previousUi.getClearOnShrink(); const onDebug = previousUi.onDebug; if (previousUi instanceof TuiMainScreen) { this.mainScreenRenderState = previousUi.captureRenderState(); } previousUi.stop({ preserveScreen: true }); previousUi.setFocus(null); previousUi.clear(); if (TuiLayouts.isViewportTUI(previousUi)) previousUi.setLayoutRoot(undefined); if (nextUi instanceof TuiMainScreen && this.mainScreenRenderState) { nextUi.restoreRenderState(this.mainScreenRenderState); } this.renderer = nextUi; this.options.tuiMode = mode; this.mountInteractiveTui(nextUi, components); nextUi.invalidate(); nextUi.setFocus(focus); if (!startRenderer) return true; nextUi.start();Không đổi được khi đang có overlay. Renderer cũ dừng với preserveScreen: true để không in lại hay đẩy con trỏ đi; cây được tháo ra và gắn y nguyên vào renderer mới, focus được trả về đúng component cũ. Nếu đi từ regular sang fullscreen, state vi sai của main screen được chụp lại, để khi quay về regular, TuiMainScreen mới tiếp tục từ chỗ cũ thay vì in lại cả lịch sử; đó là “render-state handoff” mà CHANGELOG 0.84.0 nhắc tới (CHANGELOG.md:241). Lúc thoát fullscreen, nếu fullscreenExitOutput là "transcript" (mặc định), interactive mode chuyển hẳn về regular rồi gọi renderNow() để in transcript vào main screen (:883–890); giá trị kia, "resume-hint", chỉ in gợi ý cách mở lại session (settings-selector.ts:728).
Từ sự kiện của session tới component
InteractiveMode đăng ký một listener với AgentSession và xử lý từng sự kiện trong handleEvent() (interactive-mode.ts:3357–3369). Mỗi nhánh chỉ làm hai việc: đổi cây component, rồi ui.requestRender(); nhịp 16 ms lo phần còn lại.
case "message_start": if (event.message.role === "custom") { this.addMessageToChat(event.message); this.ui.requestRender(); } else if (event.message.role === "user") { this.addMessageToChat(event.message); this.updatePendingMessagesDisplay(); this.ui.requestRender(); } else if (event.message.role === "assistant") { this.streamingComponent = new AssistantMessageComponent( undefined, this.hideThinkingBlock, this.getMarkdownThemeWithSettings(), this.hiddenThinkingLabel, this.outputPad, this.getMarkdownTransformers(), ); this.streamingMessage = event.message; this.chatContainer.addChild(this.streamingComponent); this.streamingComponent.updateContent(this.streamingMessage, true); this.ui.requestRender(); } break;| Sự kiện | Thay đổi trên cây |
|---|---|
message_start | User, custom: thêm component vào chatContainer. Assistant: tạo AssistantMessageComponent đang stream và thêm vào cuối chat. |
message_update | updateContent(message, true); với mỗi tool call mới xuất hiện trong luồng, tạo một ToolExecutionComponent ngay cả khi đối số chưa stream xong, hoặc cập nhật đối số của hàng đã có (:3486–3512). |
message_end | Cập nhật lần cuối với isStreaming = false; báo lỗi cho các tool đang chờ nếu bị huỷ hay lỗi, hoặc báo đối số đã đủ để tool sửa file tính diff. |
tool_execution_start, _update, _end | Đánh dấu bắt đầu, cập nhật kết quả từng phần, kết quả cuối kèm thời gian chạy. Lời gọi lồng (từ script codemode) được vẽ trong hàng của tool cha. |
agent_end | Tắt chỉ báo working và thanh tiến trình của terminal; gỡ component stream còn sót. |
entry_appended loại compaction | Xoá chatContainer và dựng lại từ các entry còn trong context (xem chương Compaction). |
Mọi sự kiện đều bắt đầu bằng footer.invalidate(), vì footer hiện số token, chi phí, phần trăm context và có thể đổi sau bất kỳ sự kiện nào. Một ToolExecutionComponent là một Box nền toolPendingBg chuyển sang toolSuccessBg hay toolErrorBg khi có kết quả, và vẽ phần gọi và phần kết quả bằng renderer riêng của tool nếu tool có (tool-execution.ts:104–110; cách tool khai báo renderer thuộc chương Công cụ). Tin nhắn user là một Markdown tự tô nền userMessageBg và tự đệm, không bọc trong Box, vì Box sẽ giữ thêm một bản sao rộng kín màn hình của mỗi dòng (user-message.ts:38–58). Cả tin nhắn user lẫn assistant đều gắn dấu OSC 133, nhờ đó phím nhảy giữa các lời nhắc hoạt động ở cả hai chế độ (mục TuiAltScreen).
Theme và system theme#
Theme: token ngữ nghĩa thành mã ANSI
Component của coding agent không viết màu cứng. Chúng gọi theme.fg("accent", text) hay theme.bg("toolSuccessBg", text) theo tên vai trò. Danh sách vai trò là THEME_TOKENS, 56 token, mỗi token thuộc ô chữ (foreground) hoặc ô nền (background), có mô tả và có thể có token dự phòng (theme-tokens.ts:24–101). Một file theme JSON gán giá trị cho các token, với sáu dạng giá trị: hex, oklch(...), okhsl(...), chỉ số 0–255, tên biến trong vars, và chuỗi rỗng nghĩa là màu mặc định của terminal (themes.md:83–92).
Ở tầng dưới, pi-tui có kiểu Color là chỉ số bảng màu, sRGB hoặc OKLCH, và mọi màu đều đổi được sang sRGB nên phép trộn màu luôn chạy (colors.ts:4–24). OKLab là không gian màu theo cảm nhận: L là độ sáng từ 0 tới 1, hai trục a, b là cặp màu đối nghịch; OKLCH là dạng toạ độ cực của nó (độ sáng, độ đậm chroma, góc màu hue); OKHSL đo saturation tương đối với mức tối đa mà gam sRGB cho phép ở hue và độ sáng đó, nên mọi giá trị đều hiển thị được và cùng saturation trông đậm như nhau ở mọi hue (README.md:107–109). Khi ghi ra, màu thành 38;2;r;g;b trên terminal truecolor hoặc chỉ số gần nhất của bảng 256 màu (colors.ts:299–307).
const addToken = (token: ThemeToken, value: string | number, isBackground: boolean): string => { if (value === "") { (isBackground ? this.defaultBackgroundTokens : this.defaultForegroundTokens).push(token); return isBackground ? "\x1b[49m" : "\x1b[39m"; } const color = parseColor(value); this.concreteColors[token] = color; (isBackground ? concreteBackgrounds : concreteForegrounds).push(color); return isBackground ? backgroundAnsi(color, mode) : foregroundAnsi(color, mode); };fg(color: ThemeColor, text: string): string { const ansi = this.tokenAnsi(this.fgAnsi, color); if (this.dimTokens.has(color)) return `${ansi}\x1b[2m${text}\x1b[22;39m`; return `${ansi}${text}\x1b[39m`;}bg(color: ThemeBg, text: string): string { const ansi = this.tokenAnsi(this.bgAnsi, color); return `${ansi}${text}\x1b[49m`;}fg() nằm trên đường nóng của render, nên mọi chuyển đổi màu được làm một lần lúc tạo theme. Token rỗng thành \x1b[39m hoặc \x1b[49m, tức màu mặc định của terminal; token mờ được thêm SGR 2. Biến toàn cục theme là một Proxy đọc theme thật từ một khoá Symbol.for(...) trên globalThis, để mọi bản sao của module (Node và jiti khi chạy dev) thấy cùng một theme (theme.ts:647–664). Đổi theme thì controller gọi ui.invalidate() rồi requestRender(); mọi cache dòng bị xoá và component dựng lại chuỗi màu (theme-controller.ts:249–252).
System theme: màu lấy từ chính terminal
Vấn đề. Một bảng màu cố định, dù là dark hay light, hiếm khi hợp với theme mà người dùng đã chọn cho terminal (Solarized, Catppuccin, Gruvbox...), và có thể khó đọc trên nền xám trung tính. Từ 0.99.0, ngay trước bản 1.0, theme mặc định là system (coding-agent CHANGELOG.md:445): Pi hỏi terminal màu chữ, màu nền và 16 màu ANSI, lấy hue của từng vai trò từ bảng màu đó, và tự giải độ sáng sao cho mỗi vai trò đạt một mức tương phản (themes.md:5–12). Bài “There are many themes, but this one is yours” của Earendil tóm tắt đúng các ý này: đọc màu của terminal, giải độ sáng từ quy tắc tương phản bằng đa thức đã khớp, làm việc trong OKLCH và OKHSL, và giới hạn chroma theo chroma của bảng màu (earendil.com/posts/system-theme).
Bước 1: hỏi terminal.
const TERMINAL_PALETTE_SIZE = 16;/** OSC 10 and 11 plus OSC 4 for every palette color. */const TERMINAL_COLOR_REPLY_COUNT = 2 + TERMINAL_PALETTE_SIZE;/** * Default colors, palette colors 0-15, and a trailing primary device attributes (DA1) request. * Every terminal answers DA1 and terminals answer in order, so the DA1 reply marks the end of * the color replies, including for terminals that ignore the color queries. */const TERMINAL_COLOR_QUERY = `\x1b]10;?\x07\x1b]11;?\x07${Array.from( { length: TERMINAL_PALETTE_SIZE }, (_, index) => `\x1b]4;${index};?\x07`,).join("")}\x1b[c`;const DEVICE_ATTRIBUTES_RESPONSE_PATTERN = /^\x1b\[\?[\d;]*c$/;Mười tám câu hỏi màu và một DA1 đi trong một lần ghi. Câu trả lời tới qua stdin và bị TuiBase nuốt trước mọi xử lý khác (tui.ts:1159–1191). Truy vấn xong khi DA1 tới hoặc khi đủ mười tám câu trả lời; bảng màu chỉ được đặt khi có đủ 16 màu. Interactive mode chờ tối đa 100 ms (theme-controller.ts:20–40); câu trả lời tới muộn hơn, ví dụ qua SSH chậm, vẫn được áp dụng qua onLateReply. Trong lúc chờ, system theme render ở thang xám (saturation 0) thay vì đoán màu (theme.ts:536–549), và header lúc khởi động, vốn “nướng” sẵn màu vào chuỗi, chỉ được dựng sau khi màu đã về (interactive-mode.ts:1005–1008). Tuỳ những gì terminal trả lời, theme được sinh theo một trong ba bậc (system-theme.ts:16–19): có cả nền và bảng màu thì hue lấy từ bảng màu; chỉ có nền thì dùng hue riêng của từng họ màu; không có gì thì dùng chỉ số ANSI 0–15 và màu mặc định để terminal tự vẽ, chữ phụ thì làm mờ bằng SGR 2 thay vì dùng màu ANSI 8 (bright black), vốn gần như vô hình trong vài theme (system-theme.ts:637–655).
Bước 2: họ màu. Mỗi token thuộc một họ, và mỗi họ có hue, một khoảng saturation, và một ô trong bảng ANSI để lấy hue thật của terminal:
/** A family's OKHSL hue and saturation: `max` at mid lightness, falling toward `min` at black and white. */interface Family { hue: number; saturation: { min: number; max: number }; /** ANSI palette slot the family takes its hue and saturation from. */ slot: number;}const FAMILIES = { neutral: { hue: 231.49, saturation: { min: 0.02, max: 0.08 }, slot: 8 }, blue: { hue: 231.49, saturation: { min: 0.1, max: 0.68 }, slot: 4 }, green: { hue: 158.68, saturation: { min: 0.1, max: 0.76 }, slot: 2 }, red: { hue: 20, saturation: { min: 0.1, max: 0.92 }, slot: 1 }, yellow: { hue: 82.36, saturation: { min: 0.5, max: 1 }, slot: 3 }, orange: { hue: 52, saturation: { min: 0.12, max: 0.85 }, slot: 3 }, violet: { hue: 295, saturation: { min: 0.2, max: 0.6 }, slot: 5 }, calamine: { hue: 202.43, saturation: { min: 0.1, max: 0.74 }, slot: 6 }, thinkingSlate: { hue: 231.49, saturation: { min: 0.08, max: 0.2 }, slot: 4 }, thinkingBlue: { hue: 231.49, saturation: { min: 0.2, max: 0.45 }, slot: 4 }, thinkingPeriwinkle: { hue: 263.25, saturation: { min: 0.3, max: 0.6 }, slot: 6 }, thinkingViolet: { hue: 295, saturation: { min: 0.4, max: 0.75 }, slot: 5 }, thinkingMagenta: { hue: 337.5, saturation: { min: 0.5, max: 0.85 }, slot: 13 }, thinkingRed: { hue: 20, saturation: { min: 0.95, max: 1 }, slot: 1 },} satisfies Record<string, Family>;Ví dụ error thuộc họ red (ô 1), mdLink thuộc blue (ô 4), accent thuộc violet (ô 5); vài token dùng ô khác để khỏi trùng màu với token tương tự, như syntaxString lấy ô 2 (system-theme.ts:68–131). Saturation của họ theo một đường cong chuông: lớn nhất ở độ sáng trung bình, giảm dần về min ở gần đen và gần trắng (:443–453).
Bước 3: mức tương phản là một đa thức.
/** * Target-lightness curves: a polynomial in the surface's OKLab lightness giving the OKLab lightness a token * needs on it. `reachable` is the range of surface lightness where the level can be reached; beyond it the * level is relaxed. */interface Curve { coefficients: number[]; reachable: [number, number];}const LEVELS = { panel: { dark: { coefficients: [0.29131, -0.39746, 2.33185, -0.85524, -1.2076, 0.86276], reachable: [0, 0.979] }, light: { coefficients: [-3.74073, 27.94549, -78.44258, 112.6798, -79.60015, 22.11277], reachable: [0.348, 1] }, }, track: {/** The target lightness for a level on a surface, or undefined where the level cannot be reached. */function levelTarget(level: Level, appearance: ThemeAppearance, surfaceL: number): number | undefined { const curve: Curve = LEVELS[level][appearance]; if (surfaceL < curve.reachable[0] || surfaceL > curve.reachable[1]) return undefined; return curve.coefficients.reduce((sum, coefficient, power) => sum + coefficient * surfaceL ** power, 0);}Một “mức” (panel, subtle, readable, emphasis, text...) là hàm cho biết token cần độ sáng OKLab bao nhiêu khi nằm trên một bề mặt có độ sáng cho trước. Mỗi mức có hai đa thức, cho nền tối và nền sáng, với hệ số được khớp theo bản thiết kế theme tham chiếu, và một khoảng reachable ngoài đó mức bị nới (system-theme.ts:11–14). Thay số vào để thấy hình dạng của chúng:
| Ví dụ minh hoạ: nền (OKLab L) | panel | subtle | readable | text |
|---|---|---|---|---|
| Tối, 0,10 | 0,274 | 0,564 | 0,669 | 0,893 |
| Tối, 0,20 | 0,297 | 0,572 | 0,675 | 0,897 |
| Tối, 0,30 | 0,351 | 0,600 | 0,699 | 0,915 |
| Sáng, 1,00 | 0,651 | 0,456 | ||
| Sáng, 0,90 | 0,505 | 0,209 |
Các giá trị được tính tay từ hệ số trong LEVELS. Trên nền tối, độ sáng đích gần như cố định (chữ thân bài khoảng 0,9 dù nền là 0,1 hay 0,2); trên nền sáng, khoảng cách cần thiết lớn dần khi nền tối đi (chữ cách nền 0,54 khi nền trắng, 0,69 khi nền 0,9). Đúng như chú thích đầu file mô tả.
Bước 4: quy tắc và thứ tự giải.
const each = (tokens: ThemeToken[], on: Surface[], level: Level): Rule[] => tokens.map((token) => ({ token, on, level }));const RULES: Rule[] = [ ...each(PANELS, ["background"], "panel"), { token: "text", on: ["background"], level: "text" }, { token: "text", on: ["selectedBg"], level: "textOnPanel" }, { token: "userMessageText", on: ["userMessageBg"], level: "textOnPanel" }, { token: "toolTitle", on: TOOL_PANELS, level: "textOnPanel" }, ...each(["accent", "success", "error", "warning"], ["background", "selectedBg", ...TOOL_PANELS], "readable"), { token: "muted", on: ["background", "selectedBg", "customMessageBg", ...TOOL_PANELS], level: "readable" }, { token: "dim", on: ["background", "selectedBg", "customMessageBg", ...TOOL_PANELS], level: "subtle" },Mỗi quy tắc nói một token phải đạt một mức trên một danh sách bề mặt: các panel (nền tin nhắn user, nền tool...) phải đạt panel trên nền; text đạt text trên nền và textOnPanel trên selectedBg; accent, error đạt readable trên nền và trên mọi panel tool. Vì token có thể nằm trên một panel mà bản thân panel cũng là token cần giải, SOLVE_ORDER sắp xếp theo phụ thuộc để mọi bề mặt được giải trước token vẽ trên nó (system-theme.ts:352–365).
Bước 5: giải, và nới khi không giải được.
const solve = (t: number): Map<Surface | ThemeToken, RgbColor> | undefined => { const colors = new Map<Surface | ThemeToken, RgbColor>([["background", background]]); for (const token of SOLVE_ORDER) { const targets: number[] = []; for (const rule of RULES) { if (rule.token !== token) continue; for (const surface of rule.on) { const value = target(rule.level, oklabLightness(colors.get(surface) ?? background), t); if (value === undefined || value < 0 || value > 1) return undefined; targets.push(value); } } const l = lighter ? Math.max(...targets) : Math.min(...targets); colors.set(token, PANELS.includes(token as ThemeBg) ? limitPanel(token, l) : paint(token, l)); } return colors;};let relaxation = 0;let colors = solve(0);if (!colors) { // Mid-gray backgrounds cannot fit every level: relax as little as possible. Full relaxation always fits. let [low, high] = [0, 2]; colors = solve(high); for (let index = 0; index < 20; index++) { const middle = (low + high) / 2; const attempt = solve(middle); if (attempt) [high, colors] = [middle, attempt]; else low = middle; } relaxation = high;}Với mỗi token, solve() tính độ sáng đích cho từng cặp (quy tắc, bề mặt), lấy giá trị khắt khe nhất (lớn nhất trên nền tối, nhỏ nhất trên nền sáng), rồi tô màu ở độ sáng đó với hue và saturation của họ. Panel còn bị giới hạn để chữ trắng (hoặc đen) vẫn đạt tỷ lệ tương phản WCAG 4,5:1 trên nó (system-theme.ts:504–520). Nền xám trung tính không chứa nổi mọi mức: đích có thể vượt khỏi 0–1, và solve(0) trả về undefined. Khi đó tham số nới t được tìm nhị phân trong 20 bước: từ 0 tới 1, các mức mạnh hơn mức sàn (readable trên nền tối, subtle trên nền sáng, system-theme.ts:341) bị nén dần về mức sàn; từ 1 tới 2, mọi mức bị kéo về phía chính bề mặt (system-theme.ts:490–502). t = 2 luôn giải được, nên luôn có kết quả.
Chữ thân bài được xử lý riêng sau cùng. Nếu màu chữ mặc định của terminal đủ đạt mức emphasis trên mọi bề mặt nó nằm, token được để rỗng, tức dùng đúng màu chữ của người dùng; nếu không, nó lấy hue của màu chữ đó ở độ sáng vừa đủ. Trong mọi trường hợp, withTextContrast() đẩy màu về phía trắng hoặc đen cho tới khi đạt 4,5:1 trên mọi bề mặt (system-theme.ts:566–585, :619–635).
Bước 6: giữ chroma của bảng màu.
/** * A source color's hue at another OKHSL lightness. Its saturation applies at its own lightness and falls off * toward black and white along the family's saturation curve, never rising above it. * * OKHSL saturation is relative to the most chroma sRGB allows at a lightness, so the same saturation can mean * more chroma elsewhere: Catppuccin Frappe's pink #f4b8e4 (chroma 0.089) would become #eb76d1 (0.180) at the * lightness the accent needs. Chroma is therefore also capped at the source's, with the same falloff. */function anchored(source: SourceColor, family: Family, lightness: number, saturation: number): RgbColor { const anchor = saturationCurve(family, source.l); const falloff = anchor > 0 ? Math.min(1, saturationCurve(family, lightness) / anchor) : 1; const color = okhslColor(source.h, source.s * falloff * saturation, lightness); const cap = source.chroma * falloff * saturation; const { l, c } = colorToOklch(color); return c <= cap ? color : colorToRgb(oklchColor(l, cap, source.h));}Khi một màu của bảng được dời sang độ sáng mà token cần, giữ nguyên saturation OKHSL nghe có vẻ đúng, nhưng saturation đó là tương đối với gam màu, và ở độ sáng khác gam cho phép nhiều chroma hơn. Ví dụ trong chú thích: màu hồng #f4b8e4 của Catppuccin Frappe có chroma 0,089, sẽ thành #eb76d1 với chroma 0,180 ở độ sáng mà accent cần, tức gấp đôi độ đậm, và bảng màu pastel không còn pastel. Vì vậy chroma bị chặn trên bằng chroma gốc, giảm dần theo cùng đường cong của họ. Đây là sửa lỗi của 1.0.0 (coding-agent CHANGELOG.md:365).
Bước 7: theo terminal khi nó đổi sáng tối. Với system theme và các cặp theme dạng "light/dark", controller bật thông báo đổi bảng màu (chế độ 2031, \x1b[?2031h; tui.ts:986–994). Terminal hỗ trợ sẽ gửi \x1b[?997;1n khi chuyển sang tối và \x1b[?997;2n khi sang sáng (terminal-colors.ts:85–91); controller hỏi lại màu và sinh lại theme (theme-controller.ts:241–247). Câu hỏi “terminal đang sáng hay tối” được quyết định bằng màu nền và màu chữ terminal báo; nếu không có màu nền thì theo báo cáo 2031, rồi biến COLORFGBG, cuối cùng mặc định là tối (theme.ts:623–636).
Quyết định thiết kế: từ scrollback tới fullscreen#
Thiết kế gốc: ghi tiếp vào scrollback của terminal
Bài blog tháng 11/2025 của Mario, viết khi pi còn là dự án cá nhân, mô tả pi-tui theo đúng những gì TuiMainScreen làm hôm nay: giữ nguyên scrollback của terminal, component theo kiểu retained mode với render(width), vẽ vi sai bắt đầu từ dòng đầu tiên thay đổi, và bọc mỗi lần cập nhật trong synchronized output CSI ?2026h/CSI ?2026l để không nhấp nháy (What I learned building an opinionated and minimal coding agent). Lựa chọn này cho transcript hành xử như output của mọi chương trình dòng lệnh: nó nằm trong lịch sử của terminal, nên cuộn, tìm kiếm, chọn chữ và copy đều dùng công cụ quen thuộc của chính terminal, và ứng dụng không phải tự cài chúng (tài liệu nói ngắn gọn: “the terminal owns scrollback”, docs/tui.md:62). README vẫn mô tả TuiMainScreen theo cách đó: “renders into the main terminal buffer and preserves terminal scrollback” (README.md:61). Thiết kế này sống tiếp nguyên vẹn thành chế độ regular: cùng thuật toán, chọn bằng tuiMode: "regular" hay --tui-mode regular, và vẫn là renderer của pi config.
Giá của scrollback
Mục Trace cho thấy mặt trái. Thứ gì đã trôi vào scrollback thì đóng băng: muốn đổi nó, TUI phải xoá cả màn hình lẫn scrollback rồi in lại toàn bộ tài liệu. Với một coding agent, những thay đổi “ở quá khứ” không hiếm: mở rộng output của mọi tool, ẩn hiện khối thinking, một tool cũ trong batch song song xong muộn. Đổi độ rộng cửa sổ cũng buộc in lại tất cả vì mọi đoạn phải ngắt dòng lại. Ở session vài nghìn dòng, đó là vài nghìn dòng mỗi lần, và chỉ synchronized output che được cảnh lịch sử chạy lại trước mắt. Chiều cao còn là một chỗ giằng co: 0.51.1 bỏ vẽ lại khi đổi chiều cao, 0.62.0 bật lại, rồi thêm ngoại lệ Termux. Bố cục cũng bị ràng buộc: editor và footer chỉ là các dòng cuối của tài liệu, không có vùng cố định; overlay phải đệm khung đủ một màn hình. Và chuột thuộc về terminal, nên không có bấm để mở rộng hay kéo thanh cuộn.
Fullscreen: ứng dụng sở hữu viewport
0.84.0 thêm hai renderer cùng interface, có cuộn do ứng dụng sở hữu, cùng VStack, HStack, ScrollView lồng nhau (CHANGELOG.md:244–245); 1.0.0 đổi mặc định sang fullscreen (coding-agent CHANGELOG.md:328), và bài “Pi 1.0” của Earendil liệt kê “fullscreen by default” trong các thay đổi lớn (Pi 1.0). Đổi lại được:
- Lượng ghi ra mỗi khung có trần là
heighthàng, bất kể thay đổi nằm đâu trong tài liệu và bất kể tài liệu dài bao nhiêu. Resize chỉ vẽ lại một màn hình. - Dock cố định: editor và trạng thái đứng yên ở đáy cửa sổ trong khi transcript cuộn bên trên (usage.md:86).
- Cuộn không bị giật về đáy:
follow: "end"chỉ theo output khi đang ở đáy; ai cuộn lên đọc thì được để yên, kèm nhãn “nhảy tới tin mới nhất”. - Tương tác của ứng dụng: tìm trong transcript, nhảy giữa các lời nhắc, bấm để ẩn hiện thinking, link bấm được, thanh cuộn, thông báo nổi, ảnh Kitty cắt theo viewport.
- Không mất transcript khi thoát, vì nó được in lại vào main screen.
Cái giá là ứng dụng phải tự làm những việc terminal vốn làm, và làm khác terminal một chút: chọn chữ, copy (OSC 52 không đáng tin nên phải dùng clipboard native), mở link, tìm kiếm, cuộn bằng con lăn. Tính năng riêng của terminal mất tác dụng khi chuột bị bắt: Ghostty không còn gạch chân và xem trước URL khi rê chuột (terminal-setup.md:65–67), cử chỉ trackpad nhanh trên iTerm2 chỉ cuộn khoảng một dòng mỗi lần (:29–39), ảnh iTerm2 thành chữ thay thế, multiplexer chậm đi vì lưu lượng chuột. Phần code cũng lớn: tui-alt-screen.ts gần 1.800 dòng, chưa kể bố cục và tìm kiếm, so với khoảng 650 dòng của tui-main-screen.ts. Vì cả hai lựa chọn đều có người cần, Pi giữ cả hai.
Một cây component, hai renderer
Điều làm việc giữ cả hai rẻ là component không biết mình đang ở renderer nào. TuiBase chứa mọi thứ chung (focus, overlay, định tuyến input, lịch render, hỏi màu), mỗi lớp con chỉ cài doRender() và vài hook vòng đời. Interactive mode dựng đúng một cây và gắn nó theo hai cách, đổi renderer lúc đang chạy bằng cách tháo cây ra và gắn sang renderer mới, chuyển cả state vi sai của main screen để không in lại lịch sử. Proxy createInteractiveTuiReference() giữ cho các tham chiếu tới TUI trong component luôn trỏ vào renderer hiện hành. Những gì chỉ có nghĩa ở một chế độ được tách bằng kiểu: handleMouse chỉ được gọi ở fullscreen; setLayoutRoot() chỉ có trên ViewportTUI; các layout vẫn có render(width) trả về tài liệu không giới hạn, nên đặt chúng trong cây của main screen cũng chỉ cho ra các dòng xếp chồng bình thường (README.md:164).
Dòng chuỗi thay vì lưới ô
Nhiều thư viện TUI, curses chẳng hạn, giữ một lưới ô (mỗi ô một ký tự và một style) và so lưới. pi-tui giữ mảng chuỗi có mã ANSI. Lợi ích: một component có thể chỉ dài mười dòng; test so chuỗi; cache là giữ lại một mảng; và phép so khung chỉ là so chuỗi từng dòng. Chi phí: kỷ luật độ rộng thuộc về component; ghép overlay, tô vùng chọn, vẽ thanh cuộn đều phải cắt chuỗi theo cột mà vẫn giữ đúng trạng thái ANSI (extractSegments, sliceByColumn, getGraphemeCellRange trong utils.ts); style phải reset ở cuối mỗi dòng; và ảnh, thứ không phải chữ, phải được nhận diện riêng ở mọi bước bằng isImageLine().
System theme thay vì một bảng màu riêng
Với theme, Pi chọn mặc định là hoà vào terminal của người dùng thay vì mang theo một bộ màu của mình. Độ dễ đọc không dựa vào việc ai đó chỉnh tay từng bảng màu, mà được bảo đảm bằng quy tắc: mỗi vai trò có mức tương phản trên mỗi bề mặt nó có thể nằm, chữ thân bài luôn đạt 4,5:1 theo WCAG. Đổi lại, cả một bộ giải nhỏ (đa thức đã khớp, tìm nhị phân mức nới, giới hạn chroma) phải chạy mỗi khi terminal báo màu, và ba bậc dự phòng cho terminal không trả lời. Theme dark và light cố định vẫn có cho ai muốn.
Cạm bẫy và trường hợp biên#
if (!isImage && visibleWidth(line) > width) { // Log all lines to crash file for debugging const crashLogPath = path.join(this.logDirectory ?? os.tmpdir(), "pi-tui-crash.log"); const crashData = [ `Crash at ${new Date().toISOString()}`, `Terminal width: ${width}`, `Line ${i} visible width: ${visibleWidth(line)}`, "", "=== All rendered lines ===", ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`), "", ].join("\n"); fs.mkdirSync(path.dirname(crashLogPath), { recursive: true }); fs.writeFileSync(crashLogPath, crashData); // Clean up terminal state before throwing this.stop(); const errorMsg = [ `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`, "", "This is likely caused by a custom TUI component not truncating its output.", "Use visibleWidth() to measure and truncateToWidth() to truncate lines.", "", `Debug log written to: ${crashLogPath}`, ].join("\n"); throw new Error(errorMsg);}- Kiểm tra chỉ có ở nhánh vi sai.
fullRender()không kiểm tra, nên một dòng quá rộng có thể lọt qua khung đầu tiên và chỉ gây lỗi ở khung sau, khi nó nằm trong vùng thay đổi. Ở fullscreen, dòng quá rộng bị cắt im lặng (tui-alt-screen.ts:1709–1712), nên một component sai có thể chạy êm ở fullscreen mà sập ở regular. Kiểm thử ở cả hai chế độ, như tài liệu khuyên (docs/tui.md:182). - Ghi thẳng ra stdout khi TUI đang chạy.
TuiMainScreendi chuyển con trỏ tương đối từhardwareCursorRow. Mộtconsole.logchen vào giữa làm con trỏ thật lệch khỏi con số đó, và khung sau ghi đè sai chỗ. Interactive mode chỉ dùngconsole.logtrướcui.start()(interactive-mode.ts:965, so với :1000). - Chuỗi màu nằm trong state. Theme đổi thì cache dòng bị xoá, nhưng chuỗi đã có mã màu cũ mà ứng dụng tự giữ thì không. Component phải dựng lại chúng trong
invalidate();AssistantMessageComponentlàm vậy bằng cách gọi lạiupdateContent()(assistant-message.ts:51–56). - Sửa quá khứ ở regular. Mọi thay đổi trên viewport là một lần in lại cả tài liệu và xoá scrollback bằng
\x1b[3J. Ở session dài, mở rộng tất cả tool output hay bật hiện thinking là những thao tác đắt ở regular, rẻ ở fullscreen. - Hàng trống còn sót.
clearOnShrinktắt mặc định để đỡ vẽ lại; bật bằng settingterminal.clearOnShrinknếu thấy hàng trống còn lại sau khi nội dung co lại (settings.md:108). - Esc chậm qua SSH. Một ESC đơn lẻ phải chờ 100 ms qua SSH trước khi được coi là phím Esc. Hạ xuống bằng
PI_TUI_ESC_TIMEOUTnếu đường truyền nhanh; đặt quá thấp thì Alt+phím kiểu cũ có thể bị tách thành Esc rồi một chữ. - Rò chuỗi nhả phím khi thoát. Với giao thức Kitty, terminal gửi cả sự kiện nhả phím. Nếu tiến trình thoát ngay sau phím Ctrl+D, sự kiện nhả có thể tới sau khi Pi đã trả terminal cho shell và hiện lên như rác, nhất là qua SSH chậm. Interactive mode vì vậy tắt giao thức Kitty rồi “xả” stdin tối đa một giây trước khi dừng (interactive-mode.ts:4282–4288, terminal.ts:435–471).
- tmux. Phím có phím bổ trợ cần
extended-keys; ảnh bị tắt; hyperlink OSC 8 chỉ bật nếutmux display-message -p '#{client_termfeatures}'cóhyperlinks(terminal-image.ts:49–68); chuột ở fullscreen chỉ báo khi giữ nút. - Phím nhả và component. Sự kiện nhả phím bị lọc trừ khi component đặt
wantsKeyRelease. Component chỉ cần phím nhấn thì không phải làm gì; component nào cần biết lúc phím được thả ra thì phải bật cờ, và chỉ nhận được khi terminal dùng giao thức Kitty. - Gỡ lỗi render.
PI_TUI_WRITE_LOGghi lại luồng ANSI thô ra file hoặc thư mục (terminal.ts:176–189);PI_TUI_DEBUG_REDRAW=1ghi lý do của mỗi lần vẽ lại toàn bộ vàopi-tui-debug.logtrong thư mục log, ở coding agent là thư mục agent (tui-main-screen.ts:322–329);PI_TUI_DEBUG=1chép mỗi khung vẽ theo đường vi sai, kèm các biến trạng thái, vào/tmp/tui(:570–597); lệnh/debugghi các dòng đã render vàopi-debug.log(usage.md:92).
Tài liệu và code lệch nhau ở v1.4.0
- README liệt kê interface
TerminalthiếudrainInput,kittyProtocolActive,setTitle,setProgress,setProgramStatusvàrestoreOnExitso vớiterminal.ts:78–132. - README nói
TuiMainScreencó “three rendering strategies” (README.md:743–749); code còn vẽ lại toàn bộ khi đổi chiều cao, khiclearOnShrinkkích hoạt, và khi xoá dòng làm viewport lùi lên. - README chỉ nhắc ngưỡng dán hơn 10 dòng; code còn tạo marker cho lần dán hơn 1.000 ký tự.
AGENTS.mddặn thêm phím mặc định vàoDEFAULT_EDITOR_KEYBINDINGShayDEFAULT_APP_KEYBINDINGS(AGENTS.md:27); ở v1.4.0 hai bảng đó tên làTUI_KEYBINDINGS(pi-tui) vàAPP_KEYBINDINGS(coding agent). Quy tắc “không viết cứng phím” cũng có vài ngoại lệ trong chính pi-tui:shift+backspace,shift+delete,shift+spacetrong editor (editor.ts:841) và Shift+Ctrl+D gỡ lỗi trong TUI (tui.ts:1112).
Đọc code ở đâu#
| Thứ tự | File | Đọc để hiểu |
|---|---|---|
| 1 | tui/README.md | Hợp đồng component, overlay, chuột, hai renderer, bố cục fullscreen. Đọc trước để có bức tranh. |
| 2 | tui.ts:117 | Component, Container, interface TUI, rồi TuiBase: lịch render (1019–1079), định tuyến input (1081–1157), overlay (752–865, 1376–1448), hỏi màu (1520–1542). |
| 3 | tui-main-screen.ts:247 | Thuật toán vi sai của chế độ regular, đọc liền một mạch tới hết hàm. |
| 4 | test/tui-render.test.ts | Đặc tả chạy được: spinner giữa màn hình, nội dung co lại, resize, Termux, ảnh Kitty, ghi theo khúc. |
| 5 | tui-alt-screen.ts:1690 | Khung fullscreen; sau đó handleViewportInput() (691) và handleMouseEvent() (912) cho phím và chuột. |
| 6 | layout.ts:379, scroll-view.ts, stack.ts | Bố cục có chiều cao ràng buộc, theo đáy, chia chỗ theo trọng số. |
| 7 | terminal.ts, stdin-buffer.ts | Raw mode, thương lượng giao thức bàn phím, cắt luồng stdin, bracketed paste, khôi phục khi thoát. |
| 8 | keys.ts:859, keybindings.ts | Khớp một tên phím với mọi cách mã hoá; sổ đăng ký hành động. |
| 9 | editor.ts:696, autocomplete.ts | Thứ tự ưu tiên phím, dán, undo, autocomplete bất đồng bộ. |
| 10 | markdown.ts:303, terminal-image.ts, image.ts | Hai lớp cache của Markdown; giao thức ảnh và cách ảnh khớp vào mô hình dòng. |
| 11 | native-platform.ts, native/*/README.md | Addon nào làm gì, nạp ở đâu, đi đường lui thế nào. |
| 12 | tui-renderer.ts, chat-viewport.ts | Composition root, proxy tham chiếu, bố cục fullscreen của coding agent. |
| 13 | interactive-mode.ts:945 | init(), switchTuiMode() (892), handleEvent() (3363). File dài 7.000 dòng; dùng grep theo tên sự kiện. |
| 14 | assistant-message.ts, custom-editor.ts, tool-execution.ts | Component tiêu biểu của ứng dụng: stream, phím của app, hàng tool. |
| 15 | system-theme.ts, theme-controller.ts, theme.ts | Bộ giải màu, việc hỏi terminal, lớp Theme; đọc cùng docs/themes.md. |
| 16 | terminal-setup.md, keybindings.md, tmux.md, tui.md | Hành vi theo từng terminal, bảng phím đầy đủ, cấu hình tmux, hướng dẫn viết component cho extension. |
| 17 | tui CHANGELOG.md:235, coding-agent CHANGELOG.md:324 | Lúc hai renderer ra đời và lúc fullscreen thành mặc định. |
Tóm tắt#
- pi-tui (khoảng 19.600 dòng, ba dependency) là một thư viện retained mode: component trả về các dòng chuỗi cho một độ rộng, TUI so khung mới với khung trước và chỉ ghi phần khác, bọc trong synchronized output
\x1b[?2026h…\x1b[?2026l. requestRender()gộp mọi lời xin thành tối đa một khung mỗi 16 ms; phím bấm được vẽ ngay ởnextTick;requestRender(true)buộc vẽ lại toàn bộ.TuiMainScreen(regular) giữ cả tài liệu làm khung trước, di chuyển con trỏ tương đối tới dòng đầu tiên đổi và ghi đè tới dòng cuối đổi; dòng mới ở đáy đẩy dòng cũ vào scrollback gốc. Thay đổi trên viewport, đổi độ rộng hay chiều cao thì xoá scrollback và in lại toàn bộ.TuiAltScreen(fullscreen, mặc định từ 1.0.0) vẽ đúng một màn hình trong alternate screen, so từng hàng và đặt con trỏ tuyệt đối. Ứng dụng sở hữu viewport:VStack,HStack,ScrollViewtheo đáy, chuột, chọn chữ, copy, tìm kiếm, nhảy giữa lời nhắc OSC 133; transcript được in lại vào main screen khi thoát.- Input đi qua
StdinBuffer(cắt chuỗi, gom nội dung dán), thương lượng giao thức bàn phím Kitty với DA1 làm mốc và modifyOtherKeys làm dự phòng, rồi tới listener, focus và component. Phím được gọi theo tên hành động, người dùng cấu hình được, mở rộng bằng declaration merging. Editorcó trần chiều cao, marker cho lần dán lớn, undo gộp theo từ, kill ring và autocomplete bất đồng bộ có kiểm tra kết quả cũ.Markdowncache dòng theo (văn bản, độ rộng) và cache token quaWeakRef; ảnh Kitty và iTerm2 chiếm đúng số dòng bằng số hàng ảnh.- Interactive mode dựng một cây component, gắn theo hai cách cho hai renderer, đổi renderer lúc đang chạy kèm chuyển state vi sai; mỗi sự kiện của session chỉ đổi cây rồi gọi
requestRender(). - System theme hỏi terminal màu chữ, màu nền và 16 màu ANSI, gán mỗi token vào một họ màu, giải độ sáng từ các mức tương phản là đa thức đã khớp, nới mức khi nền xám không chứa nổi, giữ chữ thân bài ở 4,5:1 và không cho màu bảng đậm hơn chroma gốc.
14Pi 1.0 đã thay đổi những gì#
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.
15Codemode, MCP và tool search#
Vì sao: từ “không MCP” tới codemode#
Năm 2025, Pi tuyên bố không hỗ trợ MCP (Model Context Protocol, giao thức để một client gọi tool do server bên ngoài cung cấp). Lý do, trình bày ở chương Lịch sử và triết lý, là chi phí ngữ cảnh: một server MCP thường khai báo sẵn hàng chục tool, và mô tả của chúng chiếm một phần đáng kể cửa sổ ngữ cảnh (context window) trước khi người dùng gõ chữ nào. Thay vào đó Pi khuyên dùng CLI kèm README, và “Bash is all you need”.
Ngày 2026-09-29, bài “You Said No MCP!” và bản 0.99.0 đưa MCP vào lõi (CHANGELOG.md:432). Bài viết giải thích hai điều. Một, MCP đã thay đổi kể từ đó, nhóm đã theo dõi các thay đổi ấy khoảng một năm, và muốn góp phần định hướng nó: gần với OpenAPI hơn, có kết quả có cấu trúc và khả năng tìm tool. Hai, vấn đề lớn nhất còn lại là ghép các lời gọi: “The biggest issue with MCP continues to be that it's hard to compose.” Pi trả lời bằng hai cơ chế. Chi phí ngữ cảnh được xử lý bằng exposure, nạp tool hoãn lại và system message giữa hội thoại (chương Pi 1.0). Việc ghép lời gọi được xử lý bằng codemode: mô hình không gọi từng tool một mà viết một đoạn JavaScript gọi nhiều tool, song song, lọc và gộp kết quả, rồi chỉ trả phần tóm tắt về cho mô hình.
Câu đó là chìa khoá của thiết kế. Tool bash chạy lệnh trên máy (hoặc trong sandbox) của người dùng; script codemode thì chạy bên cạnh vòng lặp agent, trong tiến trình của harness, nơi được coi là tin cậy. Một runtime JavaScript nhỏ biên dịch sang WebAssembly (QuickJS) là lớp bảo vệ quanh script, và trạng thái của script được giữ trong transcript của session chứ không trên hệ thống file. Gói pi-codemode mô tả mình đúng như vậy: chạy JavaScript do mô hình viết trong một VM QuickJS mà khả năng duy nhất là gọi các tool được tiêm vào; lời gọi lồng nhau không bao giờ vào ngữ cảnh của mô hình, chỉ output và giá trị trả về của script vào (codemode/README.md:3).
Trong repo, câu trả lời gồm hai gói mới và ba built-in extension của coding agent, thêm cùng commit 8562bcf66 (2026-09-29):
codemode, mô tả có ngân sách, models.*, store().
extension tool-searchTool tool_search: BM25 trên tool chưa khai báo, nạp kết quả cho lượt sau.
extension mcpmcp.json, kết nối nền, tool mcp__server__tool, /mcp, pi mcp.
Ba extension được khai báo là “thay được”: một extension khác đăng ký codemode, tool_search hay /mcp sẽ thay chỗ bản có sẵn thay vì chạy song song (extensions/index.ts:7–14). Cơ chế nạp extension chung ở chương Extensions; chương này đi vào bên trong ba extension đó.
Mô hình tư duy: script ở phía harness#
mô hình
│ lời gọi tool codemode { code: "<JavaScript>" }
▼
executeCodemode() (extension codemode, luồng chính của pi)
│ tạo CodemodeSandbox, mỗi tool gọi được thành một CodemodeTool
▼
Execution (host) ◄── postMessage, mọi giá trị là chuỗi JSON ──► worker thread
│ deadline, abort, bản ghi lời gọi, output QuickJS VM (wasm)
│ prelude: tools, text(),
│ ◄── { type: "call", id, name, args } ──────────────────── image(), store()...
│ ─── { type: "result", id, ok, payload } ─────────────────► script của mô hình
▼
ctx.executeTool(name, args) → NestedToolCallRunner → runToolCall()
│ kiểm tra đối số, hook tool_call / tool_result, parentToolCallId
▼
tool đích: read, bash, ... hoặc mcp__server__tool
▼ (MCP)
McpServerConnection → McpClient → StdioTransport | StreamableHttpTransport → server MCP
codemode đi xuống tới server MCP. Chỉ output của script đi ngược lên tới mô hình.- Script
- Đầu vào của tool
codemode: JavaScript thô (không phải JSON, không có code fence), chạy như thân của một hàm async, nên dùng đượcawaitvàreturnở cấp cao nhất. - VM và host
- VM là một instance QuickJS riêng, có bộ nhớ tuyến tính riêng, sống trong một worker thread. Host là phía Node của pi giữ bảng tool, deadline, tín hiệu huỷ và bản ghi lời gọi.
- Prelude
- JavaScript chạy trong VM trước script, dựng
tools,ALL_TOOLS,text(),image(),exit(),store()/load(),consolevà các global, trên một cầu nối (bridge) duy nhất tới host. - Lời gọi lồng nhau (nested tool call)
- Một lời gọi tool do script phát ra. Nó đi qua pipeline tool của agent như lời gọi của mô hình, nhưng không vào transcript; chỉ một bản ghi có giới hạn của nó được lưu trên kết quả của lời gọi
codemode. - Namespace
- Nhóm tool, thường là một server MCP, tên
mcp__<server>.
Data model của pi-codemode#
Gói không biết gì về pi. Với nó, một tool chỉ là một tên, vài schema để vẽ khai báo, và một hàm:
export interface CodemodeTool { /** * The script calls tools as `tools.<id>(args)`, where `<id>` is the name with characters that * are not valid in identifiers replaced by `_` (see `toCodemodeIdentifier`), and also as * `tools["<name>"](args)`. Globals are called as `<name>(args)` and must be identifiers, or * `<namespace>.<member>`, which groups them into a frozen namespace object. */ name: string; /** Shown as a doc comment in {@link renderDeclarations}, and listed in `ALL_TOOLS` for tools. */ description?: string; /** Schema of the single argument. Rendered as the parameter type; `unknown` when omitted. */ inputSchema?: CodemodeJsonSchema; /** Schema of the resolved value. Rendered as the promise type; `unknown` when omitted. */ outputSchema?: CodemodeJsonSchema; /** Globals only: `execute` receives all call arguments as an array instead of the first one. */ spread?: boolean; /** * Globals only: TypeScript parameter list and return type for {@link renderDeclarations}, for * example `(type: string, id?: string): Promise<Model[]>`. Replaces the rendering from the schemas. */ signature?: string; /** * `args` is whatever the script passed, after a JSON round trip. The return * value must be JSON-serializable; a thrown error surfaces in the script as * an `Error` with the same message. */ execute(args: unknown, context: CodemodeToolContext): Promise<unknown> | unknown;}execute(), nên tool không bao giờ nhận object sống của VM.Kết quả của một lần chạy không bao giờ là ngoại lệ. Lỗi của script, hết giờ, bị huỷ hay sandbox hỏng đều thành { ok: false, error }, và output tới lúc lỗi vẫn được giữ:
/** * One item of the script's output, in the order the script produced it: `text()` and `console.*` * produce text items, with `console: true` for `console.*`, and `image()` image items. `data` is base64. */export type CodemodeOutputItem = | { type: "text"; text: string; console?: true } | { type: "image"; data: string; mimeType: string };/** Keys the script changed with `store()`. Only successful executions report writes. */export interface CodemodeStoreWrites { set: Record<string, unknown>; /** Keys stored as `undefined`. */ delete: string[];}/** * `output` is kept for failed executions too, up to the failure. `exit()` completes with `value: undefined`. * `during`: in a {@link CodemodeSandboxBase.start} run that the script itself ended (it returned, threw, or exited), * the result being processed when it did. */export type CodemodeResult = | { ok: true; value: unknown; output: CodemodeOutputItem[]; calls: CodemodeCall[]; storeWrites: CodemodeStoreWrites; during?: CodemodeDuring; } | { ok: false; error: CodemodeError; output: CodemodeOutputItem[]; calls: CodemodeCall[]; during?: CodemodeDuring; };error.kind là script, timeout, aborted hoặc sandbox (types.ts:59–80). storeWrites chỉ có ở lần chạy thành công: script lỗi thì những gì nó store() bị bỏ.Ví dụ minh hoạ: một CodemodeResult thành công
{ ok: true,
value: "3 issue mở, 1 PR liên quan",
output: [ { type: "text", text: "đã quét 2 server", console: true } ],
calls: [ { name: "mcp__linear__list_issues", status: "ok", durationMs: 412 },
{ name: "mcp__github__search_pull_requests", status: "ok", durationMs: 655 } ],
storeWrites: { set: { lastScan: "2026-10-22" }, delete: [] } }
calls chỉ chứa lời gọi tool, không chứa global như searchTools() (types.ts:114–120).Đầu vào có định dạng riêng: JavaScript, có thể mở đầu bằng một dòng // @options: {"max_output_tokens": ..., "timeout_ms": ...}. Với provider hỗ trợ ràng buộc đầu vào bằng ngữ pháp, tool khai báo một ngữ pháp Lark chỉ cố định hình dạng dòng options, để mô hình viết script như văn bản thô thay vì một chuỗi JSON đầy ký tự thoát:
/** * Lark grammar for providers with grammar-constrained tool input. It only fixes the shape of the * options line; the options JSON and the code are checked by {@link parseCodemodeSource}. */export const CODEMODE_SOURCE_GRAMMAR = String.raw`start: options_source | plain_sourceoptions_source: OPTIONS_LINE NEWLINE SOURCEplain_source: SOURCEOPTIONS_LINE: /[ \t]*\/\/ @options:[^\r\n]*/NEWLINE: /\r?\n/SOURCE: /[\s\S]+/`;constrainedSampling: { type: "grammar", variants: { openai_lark: ... } } (codemode/tool.ts:386–387). Dòng options phải là dòng đầu; nằm ở chỗ khác thì lời gọi thất bại trước khi script chạy, thay vì bị lờ đi (source.ts:101–115).Sandbox: worker, QuickJS và giao thức host#
Vấn đề. QuickJS chạy đồng bộ. Một script while (true) {} chạy trên luồng chính của pi sẽ chặn event loop: không bộ hẹn giờ nào chạy, không tín hiệu huỷ nào tới, giao diện đứng. Script còn có thể ăn hết bộ nhớ hay làm hỏng trạng thái dùng chung. Giải pháp của CodemodeSandbox: mỗi lần execute() có một worker thread và một VM QuickJS mới tinh, mất khoảng 20 ms kể cả tạo VM; VM chỉ có hai thứ được nhập vào là một lớp WASI (đồng hồ, số ngẫu nhiên, ghi stdout/stderr mà worker vứt đi) và một điểm gọi host duy nhất (codemode/README.md:358–368).
Phía host được viết một lần cho mọi nơi VM có thể chạy. Chỗ khác nhau duy nhất là một launcher:
/** Callbacks a launcher uses to report what its VM does. */export interface VmEvents { /** A message from the VM. Validated here, so launchers can pass through anything they receive. */ message(message: unknown): void; /** The VM failed or stopped outside the bridge, for example a worker exit or a remote error. */ failure(error: CodemodeError): void;}/** The host's handle on a running VM. */export interface VmChannel { post(message: HostToWorkerMessage): void; /** Requests VM shutdown. Called once; may resolve before a worker or remote VM actually exits. */ stop(): Promise<void>;}/** * Starts a VM for one execution. Rejections become `sandbox` errors with the rejection's message. * The VM may start reporting messages before the returned promise settles. */export type VmLauncher = (data: VmStartData, events: VmEvents) => VmChannel | Promise<VmChannel>;CodemodeSandbox), luồng gọi (InlineCodemodeSandbox), và một isolate khác qua RPC (RemoteCodemodeSandbox), xem mục runtime từ xa.Hai phía chỉ trao đổi các message sau, và mọi đối số, kết quả, giá trị đều đi qua dưới dạng chuỗi JSON: worker chuyển chuỗi vào và ra VM, không tự dựng giá trị có cấu trúc (protocol.ts:4–8).
export type WorkerToHostMessage = | { type: "call"; id: number; target: "tool" | "global"; name: string; args: string | undefined } | { type: "output"; item: CodemodeOutputItem } /** * `writes` is a JSON array of `[key, json]` for `store()` and `[key]` for deletions. Deterministic mode: `during` * is the result the VM was processing when the script finished (0: its first run) and the calls issued by then. */ | { type: "done"; ok: true; value: string | undefined; writes: string; during?: CodemodeDuring } | { type: "done"; ok: false; error: ScriptErrorJson; during?: CodemodeDuring } /** The VM failed outside the script's control, for example a wasm trap. */ | { type: "crash"; message: string } /** Deterministic mode, before the script runs: what the VM is, so a replay can refuse a different one. */ | { type: "started"; fingerprint: string } /** * Deterministic mode, after the script waits again: having processed result `seq` (0: the script's first run), it * has issued `issued` calls in total. */ | { type: "idle"; seq: number; issued: number };export type HostToWorkerMessage = /** * `payload` is the JSON result when `ok`, otherwise the error message. Deterministic mode: `seq` numbers the * results from 1 in the order the VM applies them, and the VM's clock reads `now` while it processes this one. */ { type: "result"; id: number; ok: boolean; payload: string | undefined; seq?: number; now?: number; /** Deterministic mode, `ok: false`: a JSON object whose properties the script's `Error` gets. */ properties?: string; };started, idle, seq, now và properties chỉ dùng ở chế độ tất định.Launcher của worker thread:
function workerLauncher(options: CodemodeSandboxOptions): VmLauncher { return async (data, events) => { let module: CodemodeWasmModule; try { module = await (options.wasm ?? loadQuickJSWasm()); } catch (error) { throw new Error(`Failed to load QuickJS: ${errorMessage(error)}`); } const interrupt = new SharedArrayBuffer(4); const workerData: WorkerData = { ...data, wasm: module, interrupt }; const releaseWorker = options.acquireWorker?.(); let worker: Worker; try { worker = new Worker(options.workerUrl ?? defaultWorkerUrl(), { workerData }); } catch (error) { releaseWorker?.(); throw new Error(`Failed to start worker: ${errorMessage(error)}`); } if (releaseWorker) worker.once("exit", releaseWorker); const stop = async (): Promise<void> => { Atomics.store(new Int32Array(interrupt), 0, 1); // A non-interruptible built-in can delay Bun's terminate() until it returns (#10748). // Do not hold up the result while that worker exits. void worker.terminate().catch(() => undefined); // terminate() refs the worker internally; unref it after requesting termination. worker.unref(); };terminate() của Bun không dừng được một luồng đang quay trong wasm; VM thăm dò cờ đó.Trong worker, startVm() tạo VM với giới hạn bộ nhớ, giới hạn stack và hàm ngắt, chạy prelude, rồi bọc script vào một hàm async. Cầu nối duy nhất từ prelude ra host chỉ nhận giá trị nguyên thuỷ:
try { // Called from the prelude with primitives only. const bridge = vm.newFunction("bridge", (kind, a, b, c) => { switch (kind.toString()) { case "call": case "global": issued++; post({ type: "call", id: a.toNumber(), target: kind.toString() === "call" ? "tool" : "global", name: b.toString(), args: c === undefined || c.isUndefined ? undefined : c.toString(), }); break; case "output": post({ type: "output", item: a.toString() === "image" ? { type: "image", data: b.toString(), mimeType: c.toString() } : a.toString() === "console" ? { type: "text", text: b.toString(), console: true } : { type: "text", text: b.toString() }, }); break; // The prefix shares the first line with the script so reported line numbers // match the script as written. let fn: JSValueHandle; try { fn = vm.evalCode(`(async (tools, console) => {${data.code}\n})`, "codemode.js"); } catch (error) { if (!(error instanceof JSException)) throw error; post({ type: "done", ok: false, error: describeException(error) }); return session; } vm.callFunction(run, api, fn).dispose(); fn.dispose(); drain(); if (clock) post({ type: "idle", seq: 0, issued });drain() chạy các promise job từng cái một và thăm dò cờ ngắt sau mỗi 50 job, để cả while (true) await null cũng dừng được (vm.ts:238–250).Phía host, mỗi message call chạy tool tương ứng và gửi kết quả lại:
private async handleCall(message: Extract<WorkerToHostMessage, { type: "call" }>): Promise<void> { const { id, name } = message; const isTool = message.target === "tool"; const record: CodemodeCall | undefined = isTool ? { name, status: "cancelled", durationMs: 0 } : undefined; if (record) this.calls.push(record); const pending: PendingCall = { record, startedAt: performance.now(), controller: new AbortController() }; this.pending.set(id, pending); let status: CodemodeCallStatus; let reply: HostToWorkerMessage; try { const tool = (isTool ? this.executors.tools : this.executors.globals).get(name); if (!tool) throw new Error(`Unknown ${isTool ? "tool" : "global"} "${name}"`); const args: unknown = message.args === undefined ? undefined : JSON.parse(message.args); const value = await tool.execute(args, { signal: pending.controller.signal }); reply = { type: "result", id, ok: true, payload: value === undefined ? undefined : JSON.stringify(value) }; status = "ok"; } catch (error) { reply = { type: "result", id, ok: false, payload: errorMessage(error) }; status = "error"; } // Already cancelled by finish(): the record keeps "cancelled" and the // VM is gone or going. if (!this.pending.delete(id)) return; if (record) { record.status = status; record.durationMs = performance.now() - pending.startedAt; } this.post(reply);}AbortController riêng, bị huỷ khi script kết thúc; bản ghi khởi đầu là cancelled và chỉ thành ok/error nếu tool trả về trước đó. Lỗi của tool thành một message lỗi; trong script nó là một Error cùng thông điệp.- Khởi tạoConstructor của
Executionđặt deadline (chỉ khitimeoutMshữu hạn) và lắng nghe tín hiệu huỷ, rồi xin launcher một kênh. Câu trả lời phát sinh trước khi kênh có được xếp hàng rồi gửi sau (execution.ts:206–258). - ChạyMessage
outputđược nối vào output,callchạy tool,donevàcrashkết thúc. Message không giải mã được, ví dụ vì script đã sửa built-in khiến prelude gửi dữ liệu hỏng, làm lần chạy thất bại với lỗisandbox“bridge broken” thay vì làm sập pi (:315–328). - Kết thúcSự kiện kết thúc đầu tiên thắng:
done,crash, launcher lỗi, deadline, hay huỷ.finish()huỷ mọi lời gọi tool đang chờ, yêu cầu kênh dừng, và resolve kết quả khi yêu cầu đó xong; worker có thể thoát muộn hơn (:410–448).
Prelude: những gì script nhìn thấy#
Script và prelude chạy chung một realm. Vấn đề đã xảy ra thật: một script gán Array.prototype.toJSON = ... làm hỏng JSON mà prelude gửi cho host, làm sập pi và để lời gọi tool treo mãi (CHANGELOG.md:241). Từ 1.0.4, việc đầu tiên prelude làm là đóng băng mọi built-in:
// Freeze every object reachable from the built-in globals, plus the intrinsics that are only// reachable from instances (iterator and generator prototypes, %TypedArray%).//// Freezing alone breaks ordinary code through the "override mistake": a data property that is// read-only on a prototype cannot be assigned on an instance either, so// this.name = "MyError" in an Error subclass would throw. Commonly overridden properties// become accessors whose setter defines an own property on the instance instead.(function lockdown() { for (const key of Reflect.ownKeys(globalThis)) { const descriptor = Object.getOwnPropertyDescriptor(globalThis, key); add(descriptor.value); add(descriptor.get); add(descriptor.set); if ("value" in descriptor && descriptor.configurable) { Object.defineProperty(globalThis, key, { writable: false, configurable: false }); } } while (queue.length > 0) { const object = queue.pop(); add(Object.getPrototypeOf(object)); const descriptors = Object.getOwnPropertyDescriptors(object); for (const key of Reflect.ownKeys(descriptors)) { const descriptor = descriptors[key]; if ("value" in descriptor) { add(descriptor.value); if ( descriptor.writable && descriptor.configurable && (object === Object.prototype || OVERRIDABLE.has(key)) ) { allowOverride(object, key, descriptor.value, descriptor.enumerable); } } else { add(descriptor.get); add(descriptor.set); } } Object.freeze(object); }this.name = "MyError" trong một lớp con của Error sẽ ném lỗi. Vì thế vài thuộc tính hay bị ghi đè được thay bằng accessor tự định nghĩa thuộc tính riêng trên instance (prelude-source.ts:71–92).Sau đó prelude cất cầu nối vào closure, để script không gọi thẳng được, và dựng tools trên nó:
function caller(kind, name, spread) { return (...args) => new Promise((resolve, reject) => { let json; try { json = serialize(spread ? args : args[0]); } catch (error) { reject(error); return; } const id = nextId++; pending.set(id, { resolve, reject }); bridge(kind, id, name, json); });}const tools = Object.create(null);const allTools = [];for (const { name, jsName, description } of parse(toolsJson)) { const fn = caller("call", name); // The first tool wins when two names normalize to the same identifier. if (!(jsName in tools)) { tools[jsName] = fn; allTools.push(Object.freeze({ name: jsName, description })); } if (!(name in tools)) tools[name] = fn;}Object.freeze(tools);Object.freeze(allTools);my-tool thành my_tool, identifier.ts) và tên gốc, dùng được qua tools["my-tool"].tools mà script nhận là một Proxy. Đọc một thành viên không tồn tại ném lỗi kèm gợi ý, thay vì trả undefined rồi hỏng ở chỗ khác với “not a function”:
const comparable = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");function guard(target, label, names, hint) { return new Proxy(target, { get(object, property, receiver) { if (typeof property !== "string" || property in object || property in Object.prototype || property === "then" || property === "toJSON") { return Reflect.get(object, property, receiver); } const wanted = comparable(property); const exact = names.filter((name) => comparable(name) === wanted); const close = exact.length > 0 ? exact : names.filter((name) => wanted && (comparable(name).includes(wanted) || wanted.includes(comparable(name)))); let message = label + "." + property + " does not exist."; if (close.length > 0) message += " Did you mean " + close.slice(0, 5).map((name) => label + "." + name).join(", ") + "?"; else if (names.length <= 20) message += " Available: " + names.join(", ") + "."; if (hint) message += " " + hint; message += ' Check for a member with "' + property + '" in ' + label + "."; throw new TypeErrorCtor(message); }, });}const toolsProxy = guard( tools, "tools", allTools.map((tool) => tool.name), "ALL_TOOLS lists every tool; searchTools(query) finds tools by topic.",);Ví dụ minh hoạ: script gọi tools.Bash(...) nhận TypeError: tools.Bash does not exist. Did you mean tools.bash? ALL_TOOLS lists every tool; searchTools(query) finds tools by topic. Check for a member with "Bash" in tools., vì comparable() bỏ qua hoa thường và mọi ký tự ngoài chữ cái và chữ số. Hệ quả là kiểm tra kiểu typeof tools.x cũng ném; phải dùng "x" in tools (CHANGELOG.md:350).
Output được giữ ở host tới khi script kết thúc, nên phải có trần. Từ 1.0.1, vượt trần làm script thất bại, kể cả khi script bắt lỗi đó:
let outputChars = 0;let outputItems = 0;// Past the output limits the script fails: done() reports the error, so catching it does not// resume output, and the host ends the script.function output(kind, data, mimeType) { if (finished) return; outputChars += data.length; outputItems++; if (outputChars > ${MAX_OUTPUT_CHARS} || outputItems > ${MAX_OUTPUT_ITEMS}) { const error = new RangeErrorCtor( "script output exceeded the limit of ${MAX_OUTPUT_CHARS} characters or ${MAX_OUTPUT_ITEMS} text(), image(), and console calls. " + "Print a summary instead, or write large data to a file with a tool.", ); done(false, describeError(error)); throw error; } bridge("output", kind, data, mimeType);}done(false, ...) trước khi ném nghĩa là script có catch lỗi cũng không ghi thêm output được: kết cục đã được báo cho host.Các global còn lại theo cùng tinh thần. store(key, value) và load(key) đồng bộ, làm việc trên một ảnh chụp JSON truyền vào lúc khởi động, tối đa 262.144 ký tự JSON mỗi giá trị và 1.048.576 ký tự cho tất cả (prelude-source.ts:286–326). image() chỉ nhận dữ liệu base64 hợp lệ có chữ ký PNG, JPEG, GIF hoặc WebP, và lấy kiểu MIME từ chữ ký thay vì từ kiểu khai báo, vì một ảnh hỏng được lưu vào session sẽ làm mọi request sau bị provider từ chối (:403–429). Cuối cùng, VM không có timer hay I/O, nên một script đang chờ mà không có lời gọi tool nào dở dang thì không bao giờ tiếp tục được; prelude phát hiện và kết thúc nó ngay:
stalled() { if (finished || pending.size > 0) return false; done( false, stringify({ name: "Error", message: "The script is waiting on a promise that can never settle: no tool call is pending, and timers do not exist here.", }), ); return true;},Sinh khai báo TypeScript cho tool#
Để viết đúng tools.x(args), mô hình cần biết args trông thế nào và lời gọi trả về gì. Tool khai báo bằng JSON Schema, nhưng mô hình đọc TypeScript tốt hơn nhiều so với một khối schema lồng nhau. declarations.ts dịch schema sang biểu thức kiểu TypeScript:
/** * Convert a JSON Schema to a TypeScript type expression: objects on one line (`{ a: string; b?: number; }`) with properties sorted by name, * or one property per line with `//` comments when a property has a description or constraints the * type cannot express (`// @integer @minimum 1 @default 20`); `Array<T>` for arrays. Local references * (`#/$defs/...`, `#/definitions/...`) resolve against `schema`; recursive and remote references * render as `unknown`. A result longer than `maxChars` is rendered again without the constraint tags, * and renders as `unknown` if it is still too long. */export function schemaToType(schema: CodemodeJsonSchema, options: { maxChars?: number } = {}): string { const render = (tags: boolean) => toType(schema, { root: schema, resolving: new Set(), expansions: 0, tags }); const type = render(true); if (options.maxChars === undefined || type.length <= options.maxChars) return type; const untagged = render(false); return untagged.length <= options.maxChars ? untagged : "unknown";}maxChars (mặc định 16.000 ký tự cho đầu vào, declarations.ts:10) được vẽ lại không có tag, rồi thành unknown nếu vẫn quá dài. Tham chiếu $ref cục bộ được mở tối đa 32 lần cho mỗi schema, tham chiếu đệ quy hay từ xa thành unknown.Ràng buộc mà kiểu TypeScript không diễn đạt được, như integer, cận trên dưới, độ dài, pattern, default, được viết thành tag trên dòng chú thích của thuộc tính (1.2.0, declarations.ts:249–301). Thuộc tính được sắp theo tên; có mô tả hay tag thì mỗi thuộc tính một dòng, không thì cả object nằm trên một dòng (:303–340). Mẫu khai báo mà mô hình đọc cho từng tool ghép mô tả với một câu declare:
/** * A tool's sample: the description followed by the tool's declaration. Used for tool * listings and `ALL_TOOLS` entries. */export function renderToolSample( tool: Pick<CodemodeTool, "name" | "description" | "inputSchema" | "outputSchema">, options: { inputMaxChars?: number } = {},): string { const declaration = `declare const tools: { ${renderToolSignature(tool, options)} };`; return `${tool.description?.trim() ?? ""}\n\ncodemode tool declaration:\n\`\`\`ts\n${declaration}\n\`\`\``;}Ví dụ minh hoạ: lần theo code với một schema đầu vào
{ "type": "object", "required": ["query"],
"properties": {
"query": { "type": "string", "description": "Search text" },
"limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 } } }
↓ schemaToType()
{
// @integer @minimum 1 @maximum 50 @default 10
limit?: number;
// Search text
query: string;
}
limit đứng trước query vì thuộc tính được sắp theo tên; @integer đứng đầu, rồi các tag theo thứ tự của TAG_KEYWORDS, @default sau cùng.Kiểu trả về đến từ outputSchema. Tool của coding agent không có output schema được gán một schema { type: "string" }, nên lời gọi trả về Promise<string> (codemode/tool.ts:141–156). Tool MCP luôn có output schema là CallToolResult mà script nhận, với output schema riêng của tool nằm ở structuredContent (mcp/tools.ts:155–174). Từ 1.2.0 kiểu đó được viết ra đầy đủ thay vì một kiểu generic CallToolResult<T> mà describeTool() và ALL_TOOLS hiện ra mà không định nghĩa (CHANGELOG.md:134).
Tất định, giới hạn và runtime từ xa#
Các giới hạn
| Giới hạn | Giá trị | Nơi đặt |
|---|---|---|
| Bộ nhớ VM | 256 MB trong coding agent; vượt thì InternalError: out of memory trong script | execute.ts:51–56 |
| Output | 16 Mi ký tự (text và base64 ảnh), 100.000 item | prelude-source.ts:36–43 |
| Output đưa cho mô hình | 10.000 token ước lượng (4 ký tự/token), giữ đầu và cuối, bản đầy đủ ghi ra file tạm; đổi bằng max_output_tokens | execute.ts:444–472 |
store() | 262.144 ký tự JSON mỗi giá trị, 1.048.576 ký tự cho tất cả | prelude-source.ts:34–35 |
| Deadline | Sandbox mặc định 300 giây; coding agent không đặt deadline trừ khi script có timeout_ms | execution.ts:49, execute.ts:555 |
| Stack | Guard của QuickJS 512 KiB trong worker, 256 KiB trên luồng gọi | vm.ts:53–58, inline.ts:41–42 |
| Worker đồng thời | 2 cho cả tiến trình pi (PI_CODEMODE_MAX_WORKERS), chờ tối đa 60 giây | execute.ts:57–69 |
Lời gọi models.* đồng thời | 4 mỗi script; số còn lại xếp hàng | execute.ts:49–50 |
Lần chạy tất định
execute() tự chạy tool. start() (1.2.0) thì khác: mọi lời gọi đi tới host qua onCall, và host trả lời bằng run.deliver(seq, now, id, outcome). Mục đích là phát lại: cùng script, cùng thời điểm bắt đầu, cùng seed, được trả lời bằng cùng kết quả theo cùng thứ tự, thì gọi đúng những tool đó với đúng những đối số đó và cho cùng output, trên mọi VM có cùng fingerprint (codemode/README.md:314–343). VM vốn đã gần như tất định, vì nó chỉ chạy khi host đưa thứ gì đó vào và không có timer; phần còn lại được chốt khi tạo VM:
export async function startVm(options: StartVmOptions): Promise<VmSession> { const { data, post } = options; const maxStackSize = options.maxStackSize ?? MAX_STACK_SIZE; const deterministic = data.deterministic; const clock: VirtualClock | undefined = deterministic && { creating: true, startedAt: deterministic.startedAt, now: deterministic.startedAt, seed: deterministic.seed >>> 0, }; const vm = await QuickJS.create({ wasm: options.wasm, memoryLimit: data.memoryLimitBytes, // Without a guard, deep recursion overflows the wasm stack and traps instead of throwing a // catchable RangeError. maxStackSize, interruptHandler: options.shouldInterrupt, wasi: clock ? deterministicWasi(clock) : discardOutput, ...(clock ? { timezoneOffset: 0, intrinsics: Intrinsics.ALL & ~Intrinsics.WEAK_REF } : {}), }); if (clock) clock.creating = false;random_get lấy từ một PRNG mulberry32 trên seed (vm.ts:102–125). Garbage collection không tất định, nên WeakRef và FinalizationRegistry bị bỏ khỏi môi trường.const session: VmSession = { deliver(message) { if (disposed) return; try { if (!clock) { apply(message); return; } const { seq } = message; if (seq === undefined || !Number.isInteger(seq) || seq < nextSeq || early.has(seq)) { throw new Error(`Result ${message.id} has sequence number ${seq}, expected ${nextSeq} or later, once`); } early.set(seq, message); for (let next = early.get(nextSeq); next !== undefined; next = early.get(nextSeq)) { early.delete(nextSeq); // Never backwards: a resumed host's clock may be behind the one that started the run. if (next.now !== undefined && Number.isFinite(next.now)) clock.now = Math.max(clock.now, next.now); current = nextSeq; apply(next); post({ type: "idle", seq: nextSeq, issued }); nextSeq++; } } catch (error) { post(crashMessage(error)); } },idle với số lời gọi đã phát, để host đối chiếu lần phát lại từng bước.Trước khi script chạy, VM báo một fingerprint gồm phiên bản quickjs-wasi và QuickJS, hash FNV-1a của prelude, phiên bản chế độ tất định, stack, heap và ngân sách ngắt (vm.ts:314–321), để một host phát lại có thể từ chối một VM khác. Coding agent không dùng chế độ này; Pi Durable dùng nó cho codemode bền (chương Durable: task, scheduler, generation và tool).
Ngoài Node: luồng gọi, isolate khác, Cloudflare
Cloudflare Workers và Durable Object không có worker thread. InlineCodemodeSandbox chạy VM ngay trên luồng gọi, và vì trong lúc script tính toán thì không timer nào chạy được (trên Workers cả Date.now() cũng đứng yên), nó giới hạn bằng ngân sách ngắt: đếm số lần QuickJS thăm dò hàm ngắt, quá 100.000 lần thì dừng với lỗi timeout. Ghi chú trong code đo được khoảng 50.000 lần mỗi giây CPU trên laptop Apple M và khoảng 10.000 trên Durable Object, tức chừng 2 tới 10 giây tính toán liên tục (inline.ts:5–25).
Chạy trên luồng gọi vẫn chặn luồng đó. Trong một Durable Object phục vụ một session agent, điều này nghĩa là chặn mọi request khác của session, kể cả lệnh huỷ của người dùng. RemoteCodemodeSandbox giữ vai host ở bên gọi và chỉ đẩy VM sang nơi khác:
* Problem: a VM on the calling thread blocks that thread while the script computes. In a Durable* Object that serves an agent session, a CPU-heavy script would stall every other request to the* session (including the user's abort) for seconds. Measured on Cloudflare (October 2026), a* script that computed for 10 seconds delayed the session's other requests by 10 seconds when it* ran in the session object or in a Dynamic Worker, but by about 50 ms when it ran in a Durable* Object of a separately deployed Worker.** Solution: the caller keeps the host role (`RemoteCodemodeSandbox`: tools, call records,* deadline, abort) and the remote side only runs the VM (`serveCodemodeRemote`, behind* `CodemodeSandboxDurableObject` in `@earendil-works/pi-codemode/cloudflare`).** Protocol, one RPC call per execution:** client remote* remote.execute(request, exchange) ───────▶ starts the VM (in-thread, with interrupt budget)* ◀─────── exchange([...outputs, call]) one call per tool call* runs the tool, replies with result ──────▶ VM continues* ◀─────── exchange([...outputs, done]) terminal, reply ignored* execute() resolves ◀────────────────────── VM disposedexchange mang tối đa một lời gọi, nên các lời gọi tool đồng thời là các RPC đồng thời; output được gom ở phía từ xa và gửi kèm lời gọi kế tiếp, để một script in trong vòng lặp không sinh một RPC mỗi dòng (remote.ts:25–29)./** * The remote end of `RemoteCodemodeSandbox`: a Durable Object whose one RPC method runs a script's * VM and calls back into the client for every tool call. * * Deploy it as its own Worker and bind it from the Worker that runs your agent with `script_name`. * A separate Worker is what keeps scripts off the agent's thread: a class in the same Worker can * share an isolate, and therefore a thread, with the objects that call it. * * The object keeps no state between executions and never touches storage. Executions that reach * the same instance share its thread: they interleave while they wait on tools, and a script that * computes delays the others. Name instances per session (`getByName(sessionId)`) to keep one * session's scripts together, or per execution for full independence. * * Limits apply to every request, whatever the client asks for. Override the fields in a subclass: * * export class CodemodeSandbox extends CodemodeSandboxDurableObject { * protected maxInterruptBudget = 200_000; * } */export class CodemodeSandboxDurableObject<Env = unknown> extends DurableObject<Env> { /** Interrupt polls per execution; the client may only ask for less. See `InlineCodemodeSandboxOptions`. */ protected maxInterruptBudget: number = DEFAULT_INTERRUPT_BUDGET; /** VM memory per execution; the client may only ask for less. */ protected maxMemoryLimitBytes: number = DEFAULT_REMOTE_MEMORY_LIMIT_BYTES; /** Called by `RemoteCodemodeSandbox` over RPC. Resolves when the script has settled or was stopped. */ execute(request: CodemodeRemoteRequest, exchange: CodemodeRemoteExchange): Promise<void> { return serveCodemodeRemote(request, exchange, { wasm: quickjsWasm, maxInterruptBudget: this.maxInterruptBudget, maxMemoryLimitBytes: this.maxMemoryLimitBytes, }); }}Tool codemode của coding agent#
Built-in extension codemode đăng ký một tool tắt sẵn (defaultActive: false); người dùng bật nó bằng "defaultTools": ["+codemode"] hay --tools +codemode, hoặc extension MCP bật khi có server dùng exposure codemode (codemode/index.ts:32–46). Định nghĩa tool cho thấy nó chỉ dùng các API chung của extension:
export function createCodemodeToolDefinition( options: CodemodeToolOptions = {},): ToolDefinition<typeof codemodeSchema, CodemodeToolDetails | undefined> { return { name: CODEMODE_TOOL_NAME, label: CODEMODE_TOOL_NAME, // Replaced with the declarations of the callable tools when the tool is activated. description: createCodemodeDescription([], { models: options.models === true }), promptSnippet: codemodeToolSystemPromptContribution.snippet, promptGuidelines: [...codemodeToolSystemPromptContribution.guidelines], parameters: codemodeSchema, // Scripts must not start other scripts. exposure: "model-only", prepareLoadout: (loadout) => prepareCodemodeLoadout(loadout, options), reachesTools: (input) => getScriptReach(input.code), // Capable models write the script as raw text instead of a JSON-escaped string. constrainedSampling: { type: "grammar", variants: { openai_lark: CODEMODE_SOURCE_GRAMMAR } }, // The sandbox (worker, QuickJS wasm) loads on the first call, not at startup. execute: async (toolCallId, params, signal, onUpdate, ctx) => (await loadCodemodeExecutor()).executeCodemode(toolCallId, params, signal, onUpdate, ctx, options), ...codemodeRenderers, };}execute.lazy.ts là một import() động), nên bật codemode không làm pi khởi động chậm hơn. Tài liệu extension nhấn mạnh: codemode chỉ dùng hook prepareLoadout, exposure, reachesTools và ctx.executeTool(), nên một extension khác có thể cài lại cùng hành vi dưới tên khác (docs/extensions.md:293).Phần đầu mô tả là cố định: luật của script và các global, mỗi global một dòng. Chi tiết của API models không nằm ở đây mà ở docs/codemode.md, và mô tả bảo mô hình đọc file đó khi cần:
const DESCRIPTION_INTRO = `Run JavaScript that calls other tools. The input is raw JavaScript (not JSON, no code fence), run as an async function body in a QuickJS sandbox: top-level \`await\` and \`return\` work. No Node, file system, network, or timers.- \`await tools.<name>({ ...args })\` resolves to a string, or an object if the tool's declaration says so, and rejects with an Error on failure. A tool whose declaration returns an object resolves on failure too when it returned that object; check its fields. Calls still running when the script ends are cancelled.- Optional first line: \`// @options: {"max_output_tokens": 10000, "timeout_ms": 60000}\``;/** One line per global. The details live in {@link CODEMODE_DOCS_PATH}. */function describeGlobals(models: boolean): string { const lines = [ "Globals:", "- `text(value)`, `image(dataUrlOrImageBlock)`, `console.log(...)`, and top-level `return` add output; `exit()` ends the script. With several text items, each starts with a `==> text N/M <==` line, and `console` lines follow the other output in one `<console_output>` block. `image()` also saves the image to a temp file and the result names its path.", "- `store(key, value)` and `load(key)` keep JSON values across codemode calls.", "- `ALL_TOOLS`, `await searchTools(query, { limit?, namespace? })`, `await describeTool(name)`, `await describeNamespace(name)`: find unlisted tools, such as MCP tools.", ]; if (models) { lines.push(`- \`models\`: classifiers and image generation. Read ${CODEMODE_DOCS_PATH} first.`); } return lines.join("\n");}Cách viết này là kết quả của đợt cắt giảm ở 1.0.0: với bộ tool mặc định và codemode bật, một request GPT-5.6 giảm từ khoảng 5.300 xuống 3.300 token, vì mô tả không còn nhắc lại toàn bộ khai báo và tài liệu models chuyển ra file (CHANGELOG.md:349).
Phần còn lại của mô tả, danh sách “Nested tools”, do prepareLoadout dựng mỗi khi bộ tool đổi. Setting codemode.mode chọn một trong hai cách trình bày:
/** * How the codemode tool presents tools that are both declared and callable from scripts: * - `on`: their descriptions say how scripts call them, and the codemode description * lists only the callable tools without `direct` exposure. * - `only`: the codemode description lists every callable tool, and requests leave out the * declarations of active `direct` tools. * * Listed tools carry their prompt guidelines, which the system prompt only has for declared tools. * * Listing by exposure, not by the active set, keeps the codemode description unchanged when * `tool_search` loads a tool, so loads do not redeclare codemode. */function prepareCodemodeLoadout(loadout: ToolLoadout, options: CodemodeToolOptions): ToolLoadoutChanges { const mode = options.getMode?.() ?? SETTINGS_DEFAULTS.codemode.mode; const isDirect = (tool: AgentTool) => loadout.getExposure(tool.name) === "direct"; const callable = getCodemodeCallableTools(loadout.callable); const callableNames = new Set(callable.map((tool) => tool.name)); const descriptions: Record<string, string> = {}; if (mode === "on") { for (const tool of loadout.declared) { if (callableNames.has(tool.name)) descriptions[tool.name] = describeScriptCall(tool); } } const listed = mode === "only" ? callable : callable.filter((tool) => !isDirect(tool)); const namespaces = new Map( listed.flatMap((tool) => { const namespace = loadout.getNamespace(tool.name); return namespace ? [[tool.name, namespace] as const] : []; }), ); const guidelines = new Map(listed.map((tool) => [tool.name, loadout.getPromptGuidelines(tool.name)] as const)); descriptions[CODEMODE_TOOL_NAME] = createCodemodeDescription(listed, { models: options.models === true, namespaces, guidelines, deferred: new Set( listed.filter((tool) => loadout.getExposure(tool.name) === "deferred").map((tool) => tool.name), ), inlineBudget: options.getInlineBudget?.() ?? DEFAULT_CODEMODE_INLINE_BUDGET, }); const declaredNames = new Set(loadout.declared.map((tool) => tool.name)); return { descriptions, hiddenDeclarations: mode === "only" ? callable.filter((tool) => isDirect(tool) && declaredNames.has(tool.name)).map((tool) => tool.name) : [], };}on (mặc định): tool đang khai báo giữ nguyên khai báo, mô tả của chúng thêm một dòng cách gọi từ script; codemode liệt kê các tool gọi được mà không phải direct. only: codemode liệt kê mọi tool gọi được, và khai báo của tool direct bị giấu khỏi request, nên mô hình chỉ gọi chúng qua script.Tool deferred không bao giờ được liệt kê, kể cả khi còn ngân sách; chúng tìm được bằng searchTools(). Vì tool MCP mặc định là deferred, ở cấu hình mặc định (mode: "on", chỉ có read, bash, edit, write và tool MCP) mô tả codemode không có mục “Nested tools” nào, và không đổi một byte khi server MCP kết nối. Mô hình biết cách gọi các tool chính từ dòng thêm vào mô tả của chúng:
Ví dụ minh hoạ: dòng describeScriptCall() thêm vào cuối mô tả của hai tool
bash: ... Codemode: `tools.bash(args)` resolves to `{ output, truncated, full_output_path?, exit_code, wall_time_seconds }`.
edit: ... Codemode: `tools.edit(args)` resolves to a string.
bash có output schema (bash.ts:56–62) nên lời gọi trả object; edit không có nên trả chuỗi (codemode/tool.ts:289–306).Khi có tool được liệt kê (chế độ only, hoặc tool có exposure codemode của extension), chúng chia nhau một ngân sách khoảng 3.000 token ước lượng (codemode.inlineBudget, settings-defaults.ts:57–60). Cách chọn bảo đảm mọi namespace đều có mặt trước khi namespace nào được liệt kê đủ:
/** * Pick the tool sections that fit the budget, like OpenCode's catalog: in each round every group * (tools without a namespace first, then namespaces by name) places its cheapest remaining tool; a * group whose next tool does not fit drops out while the others continue. Every namespace is * represented before any namespace is complete. */function selectCatalog(groups: readonly CatalogGroup[], budget: number | undefined): Set<string> { if (budget === undefined) return new Set(groups.flatMap((group) => group.entries.map((entry) => entry.name))); const queues = groups.map((group) => [...group.entries].sort((a, b) => a.cost - b.cost)); const shown = new Set<string>(); let remaining = budget; let active = queues.filter((queue) => queue.length > 0); while (active.length > 0) { active = active.filter((queue) => { const next = queue[0]; if (next.cost > remaining) return false; remaining -= next.cost; shown.add(next.name); queue.shift(); return queue.length > 0; }); } return shown;}Hai chi tiết cuối của định nghĩa. reachesTools trả "all" nếu script dùng một global khám phá (searchTools, describeNamespace, describeTool, ALL_TOOLS), còn không thì trả các từ trong mã nguồn, nên tools.mcp__docs__search và tools["mcp__docs__" + name] đều “chạm” namespace mcp__docs (codemode/tool.ts:358–369). Và system prompt nhận một dòng hướng dẫn: dùng codemode để gộp các lời gọi độc lập bằng Promise.allSettled, nối chúng, hay lọc output lớn (codemode/tool.ts:108–113).
Chạy một script#
executeCodemode() là cầu nối giữa sandbox không biết gì về pi và session của pi. Việc đầu tiên là biến mỗi tool gọi được (ctx.tools, trừ chính codemode) thành một CodemodeTool mà execute đi qua ctx.executeTool():
const callable = ctx ? getCodemodeCallableTools(ctx.tools) : [];// ALL_TOOLS entries carry the declaration.const guidelines = options.getToolGuidelines?.();const samples = new Map( callable.map((tool) => [tool.name, renderToolSample(toCodemodeDeclaration(tool, guidelines?.get(tool.name)))]),);const sandboxTools: CodemodeTool[] = callable.map((tool) => ({ name: tool.name, description: samples.get(tool.name), execute: async (args, { signal: callSignal }) => { const record: CodemodeNestedCall = { id: `${toolCallId}/?`, name: tool.name, args: previewArgs(args), status: "running", }; calls.push(record); publish(); const callStartedAt = performance.now(); // Only tools from ctx.tools are callable, so ctx is set here. if (!ctx) throw new Error("Tool calls need a session"); const outcome = await ctx.executeTool(tool.name, args, { signal: callSignal }); record.id = outcome.toolCall.id; record.durationMs = performance.now() - callStartedAt; if (outcome.isError) { record.status = callSignal.aborted ? "cancelled" : "error"; record.error = truncateText(textOf(outcome.result) || `Tool "${tool.name}" failed`, ERROR_PREVIEW_CHARS); } else { record.status = "ok"; } publish(); return toScriptValue(tool, outcome); },}));description của mỗi tool là mẫu khai báo đầy đủ; nó thành mục ALL_TOOLS và kết quả của describeTool(). Mỗi lời gọi được ghi một dòng CodemodeNestedCall để giao diện hiện trạng thái trong lúc chạy.Giá trị mà script nhận theo một luật đơn giản, ghi ở đầu tool.ts: tool có outputSchema trả structuredContent, kể cả khi kết quả là lỗi nhưng vẫn mang nó; tool khác trả text; một lời gọi lỗi không có structuredContent để trả thì reject:
/** * The value a script receives for a nested call: a tool that declares * `outputSchema` resolves to its `structuredContent`, also for error results that carry one (such * as MCP results with `isError`); any other tool resolves to its text content. Other failures * reject with the tool's error text. */function toScriptValue(tool: AgentTool<any>, outcome: AgentToolCallOutcome): unknown { const { result } = outcome; if (tool.outputSchema && result.structuredContent !== undefined) return result.structuredContent; const text = textOf(result); if (outcome.isError) throw new Error(text || `Tool "${tool.name}" failed`); return text;}Rồi tới phần xin worker và tạo sandbox:
// The wait for a worker counts toward the script's deadline.const deadline = sourceOptions.timeoutMs ?? Number.POSITIVE_INFINITY;const waitStartedAt = performance.now();const admission = await reserveCodemodeWorker(signal, Math.min(deadline, CODEMODE_WORKER_WAIT_MS));const waitedMs = performance.now() - waitStartedAt;let reserved = true;let sandbox: CodemodeSandbox | undefined;let result: CodemodeResult;try { sandbox = new CodemodeSandbox({ tools: sandboxTools, globals: [ ...createDiscoveryGlobals(callable, samples, options), ...(options.models && ctx ? createModelGlobals(ctx.modelRegistry, toolCallId, calls, publish, addModelUsage, addGeneratedImages) : []), ], timeoutMs: Math.max(1, Math.round(deadline - waitedMs)), memoryLimitBytes: CODEMODE_MEMORY_LIMIT_BYTES, wasm: loadQuickJSWasm(getQuickJSWasmPath()), workerUrl: getCodemodeWorkerSpecifier(), acquireWorker: () => { if (!reserved) throw new Error("The script already settled"); // From here the sandbox releases the slot when the worker actually exits. reserved = false; return releaseCodemodeWorker; }, }); const store = ctx ? readCodemodeStore(ctx.sessionManager.getBranch()) : {}; result = await sandbox.execute(code, { signal, store });} finally { await sandbox?.close(); if (reserved) { reserved = false; releaseCodemodeWorker(); }}acquireWorker chuyển việc trả chỗ cho sandbox: chỗ chỉ được trả khi worker thật sự thoát, vì trên Bun một worker bị huỷ có thể còn chạy thêm (1.3.0). Hàng đợi chờ theo thứ tự đến, tối đa 60 giây (execute.ts:74–111).store truyền vào là kết quả phát lại các entry codemode-store trên nhánh hiện tại của session:
/** Values of `load()`: the `codemode-store` entries on the branch, applied from the root. */export function readCodemodeStore(branch: readonly SessionEntry[]): Record<string, unknown> { const store = new Map<string, unknown>(); for (const entry of branch) { if (entry.type !== "custom" || entry.customType !== CODEMODE_STORE_ENTRY_TYPE || !isStoreEntryData(entry.data)) { continue; } for (const key of entry.data.delete) store.delete(key); for (const [key, value] of Object.entries(entry.data.set)) store.set(key, value); } return Object.fromEntries(store);}/tree thì mỗi nhánh thấy các giá trị được ghi trên đường của nó.Cuối cùng, kết quả được trình bày cho mô hình:
// Calls still marked running were cut off by the script ending, a timeout, or an abort.for (const call of calls) { if (call.status === "running") call.status = "cancelled";}const scriptOutput = [...result.output];if (result.ok) { const { set, delete: deleted } = result.storeWrites; if (Object.keys(set).length > 0 || deleted.length > 0) { options.appendEntry?.(CODEMODE_STORE_ENTRY_TYPE, { set, delete: deleted }); } // pi extension: a returned value is appended like text(). if (result.value !== undefined) scriptOutput.push({ type: "text", text: valueText(result.value) });}const items = formatOutput(scriptOutput);if (!result.ok) items.push({ type: "text", text: `Script error:\n${formatError(result, calls)}` });const truncated = await truncateOutput( joinAdjacentText(items), sourceOptions.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,);// After truncation, which joins the text items and moves images after them, so each path stays// next to its image and is never cut.const output = joinAdjacentText(await saveImages(truncated.items));const wallTime = ((performance.now() - startedAt) / 1000).toFixed(1);const header = `${result.ok ? "Script completed" : "Script failed"}\nWall time ${wallTime} seconds\nOutput:\n`;const details = snapshot();if (truncated.fullOutputPath) details.fullOutputPath = truncated.fullOutputPath;return { content: [{ type: "text", text: header }, ...output], details, ...(modelUsage ? { usage: modelUsage } : {}), ...(result.ok ? {} : { isError: true }),};return được nối như một text(). Có nhiều item text thì mỗi item mở đầu bằng dòng ==> text N/M <==, và mọi dòng console gom vào một khối <console_output> ở cuối (execute.ts:316–341). Mỗi ảnh được lưu thêm vào một file tạm và tên file đứng trước ảnh, vì script không ghi file được và mô hình không có cách nào khác chạm tới bytes của ảnh (:409–442).Script lỗi vẫn trả output đến lúc lỗi, theo sau là Script error:, lỗi, và danh sách lời gọi đã chạy kèm câu “they are not undone” (execute.ts:374–390).
Các global models.* được thêm khi extension tạo tool với models: true. Lời gọi classify() và generateImages() chỉ dùng provider và id của mô hình mà script đưa vào, rồi tra lại catalog: một baseUrl hay headers do script cung cấp không bao giờ được nhận credential (execute.ts:753–790), và mục catalog trả cho script bị bỏ headers vì header trong models.json có thể chứa credential (:145–150). Đối số sai hình dạng bị từ chối với hình dạng mong đợi, để mô hình tự sửa (:172–235).
Lời gọi tool lồng nhau#
Vấn đề. Script gọi tools.bash({...}). Lời gọi đó phải được đối xử như một lời gọi thật: kiểm tra đối số theo schema, chạy qua hook tool_call mà một extension phân quyền có thể dùng để chặn, có sự kiện cho giao diện, có chi phí được tính. Nhưng nó không được vào transcript, vì cả mục đích của codemode là để mô hình không thấy các kết quả trung gian. Tool lấy cả hai qua ctx.executeTool():
/** * Run a call that the tool call `parentToolCallId` made through `ctx.executeTool()`. It goes * through the agent's tool pipeline with the session's hooks, against the callable tools. */private async _executeNestedToolCall( parentToolCallId: string, name: string, args: unknown, options: ExecuteToolOptions,): Promise<AgentToolCallOutcome> { this._nestedToolCalls ??= new NestedToolCallRunner({ getTools: () => this._getCallableTools(), isSequential: () => this.agent.toolExecution === "sequential", runToolCall: (toolCall, parentId, signal, onUpdate) => { const assistantMessage = this._findLastAssistantMessage(); if (!assistantMessage) { return Promise.resolve({ toolCall, result: { content: [{ type: "text", text: "No assistant message issued this call" }], details: {} }, isError: true, }); } return runToolCall(toolCall, { tools: this._getCallableTools(), assistantMessage, context: { messages: this.agent.state.messages, tools: this.agent.state.tools }, beforeToolCall: (context) => this._beforeToolCall(context, parentId), afterToolCall: (context) => this._afterToolCall(context, parentId), signal, onUpdate, }); }, emit: async (event) => { await this._extensionRunner.emit(event); this._emit(event); }, }); return this._nestedToolCalls.execute(parentToolCallId, name, args, options);}runToolCall() của pi-agent-core chạy đúng các bước của một lời gọi do mô hình phát ra, gồm chuẩn bị đối số, kiểm tra schema, beforeToolCall, chạy và afterToolCall, nhưng không phát sự kiện và không thêm message (agent-loop.ts:812–829). Tool được tra trong bộ gọi được, không phải bộ đang khai báo.NestedToolCallRunner đặt id, giữ thứ tự khi cần, và ghi lại:
let scope = this.scopes.get(callerId);if (!scope) { scope = { recorder: new NestedCallRecorder(), nextId: 1, holdsQueue: false }; this.scopes.set(callerId, scope);}const toolCall: AgentToolCall = { type: "toolCall", id: `${callerId}/${scope.nextId++}`, name, arguments: (args ?? {}) as AgentToolCall["arguments"],};const record = scope.recorder.start(toolCall);await this.host.emit({ type: "tool_execution_start", toolCallId: toolCall.id, toolName: name, args: toolCall.arguments, parentToolCallId: callerId,});const exclusive = !scope.holdsQueue && (this.host.isSequential() || this.host.getTools().find((tool) => tool.name === name)?.executionMode === "sequential");let release: (() => void) | undefined;if (exclusive) { const previous = this.queueTail; this.queueTail = new Promise((resolve) => { release = resolve; }); await previous;}this.scopes.set(toolCall.id, { recorder: scope.recorder, nextId: 1, holdsQueue: scope.holdsQueue || exclusive,});let outcome: AgentToolCallOutcome;try { outcome = await this.host.runToolCall(toolCall, callerId, options.signal, async (partialResult) => {scope.recorder.finish(record, outcome.isError, textOf(outcome.result));// Nested results are not persisted, so their usage is only counted through the recorder.if (outcome.result.usage) scope.recorder.addUsage(outcome.result.usage);await this.host.emit({ type: "tool_execution_end", toolCallId: toolCall.id, toolName: name, result: outcome.result, isError: outcome.isError, ...(outcome.durationMs === undefined ? {} : { durationMs: outcome.durationMs }), parentToolCallId: callerId,});return outcome;<id của lời gọi codemode>/<n>. Khi agent chạy tool tuần tự, hoặc tool đích khai báo executionMode: "sequential", các lời gọi lồng nhau xếp vào một hàng đợi độc quyền; một lời gọi đang giữ hàng đợi thì các lời gọi con của nó không phải chờ chính nó (dòng 214–218).Bản ghi có giới hạn, vì một script có thể phát hàng nghìn lời gọi:
/** * Limits of the nested-call record on a tool result: arguments * over the per-call or total size are omitted, calls beyond the count are dropped, and the record * is marked incomplete when any of that happens. */export const NESTED_CALL_LIMITS = { maxCalls: 256, maxArgumentBytesPerCall: 8 * 1024, maxArgumentBytesTotal: 32 * 1024, maxErrorChars: 500,} as const;Khi kết quả của lời gọi codemode bắt đầu được phát, AgentSession lấy bản ghi ra và gắn vào chính message đó, cộng cả usage của các lời gọi lồng nhau (chúng không được lưu riêng, nên đây là nơi duy nhất chi phí của chúng được tính):
// Record the calls a tool made through ctx.executeTool() and their usage on its result message.if (this._nestedToolCalls) { if (event.type === "message_start" && event.message.role === "toolResult") { const message = event.message; const summary = this._nestedToolCalls.takeRecord(message.toolCallId); if (summary?.calls) message.nestedCalls = summary.calls; if (summary?.usage) { message.usage = message.usage ? combineUsage(message.usage, summary.usage) : summary.usage; } } else if (event.type === "agent_end") { this._nestedToolCalls.clear(); }}/** A tool call that another tool made while it ran, for example from a codemode script. */export interface NestedToolCallRecord { id: string; name: string; /** Omitted when over the size limits; `argumentsBytes` then gives their size. */ arguments?: JsonObject; /** UTF-8 size of the arguments as JSON, set when `arguments` is omitted. */ argumentsBytes?: number; /** `unfinished`: the call was still running when the calling tool finished. */ status: "ok" | "error" | "unfinished"; durationMs?: number; /** Error text, truncated. */ error?: string;}/** Bounded record of the nested calls a tool made. Results are not recorded. */export interface NestedToolCalls { calls: NestedToolCallRecord[]; /** False when calls were dropped, arguments omitted, or calls had not finished. */ complete: boolean;}ToolResultMessage, nestedCalls được ghi chú là “Kept for the session record; not sent to the model” (ai/types.ts:642–643). Kết quả của lời gọi lồng nhau không được ghi lại, chỉ có tên, đối số, trạng thái và thời gian.Sự kiện tool_call, tool_result và tool_execution_* của lời gọi lồng nhau mang parentToolCallId (nested-tool-calls.ts:110–129). Đó là cách một extension phân quyền phân biệt lời gọi của script với lời gọi của mô hình; tài liệu MCP nói rõ mọi lời gọi MCP, kể cả từ script, đều đi qua pipeline này (docs/mcp.md:250–252). Giao diện thì không vẽ chúng thành dòng tool riêng; renderer của codemode liệt kê chúng ngay trong dòng của nó, với biểu tượng trạng thái, thời gian và chi phí (renderer.ts:1–8).
pi-mcp: client, JSON-RPC và transport#
@earendil-works/pi-mcp là một client MCP nhỏ, độc lập, không phụ thuộc SDK MCP chính thức hay gói pi nào (mcp/README.md:3). Nó nói phiên bản giao thức 2025-11-25 và chấp nhận server đàm phán xuống 2025-06-18, 2025-03-26 hoặc 2024-11-05 (protocol/types.ts:4–10); batch JSON-RPC, HTTP+SSE kiểu cũ, sampling và tasks nằm ngoài phạm vi (README.md:132). Việc được chia đôi: transport lo đóng khung và I/O, client lo tương quan request, khởi tạo, timeout, huỷ và request từ server:
export interface McpTransportSendOptions { /** * Aborted once the sender no longer waits for a reply (the request was cancelled, timed out, answered, * or the client closed). A transport must not dispatch the message after that, for example when * retrying it after an authorization refresh. It does not undo a message already dispatched. */ signal?: AbortSignal;}export interface McpTransport { start(): Promise<void>; send(message: JsonRpcMessage, options?: McpTransportSendOptions): Promise<void>; close(): Promise<void>; onMessage(listener: McpTransportMessageListener): () => void; onError(listener: McpTransportErrorListener): () => void; onClose(listener: McpTransportCloseListener): () => void; setProtocolVersion?(version: string): void;}signal trong send() (1.2.0) bị huỷ khi người gửi không còn chờ trả lời; transport không được gửi message sau đó, kể cả khi đang thử lại sau một lần làm mới uỷ quyền. Trước đó, một lời gọi tool đã bị huỷ hay hết giờ vẫn có thể tới server sau khi token được làm mới (mcp/CHANGELOG.md:16).async connect(transport: McpTransport): Promise<InitializeResult> { if (this.state !== "idle") throw new Error(`Cannot connect MCP client in ${this.state} state`); this.state = "connecting"; this.transport = transport; this.disposers = [ transport.onMessage((message) => this.handleMessage(message)), // Transport errors are reported only. Pending requests fail when the transport closes. transport.onError((error) => this.emitError(error)), transport.onClose(() => this.handleTransportClose()), ]; try { await transport.start(); const capabilities: ClientCapabilities = { ...this.options.capabilities }; if (this.options.roots && capabilities.roots === undefined) capabilities.roots = {}; const result = validateInitializeResult( await this.requestInternal( "initialize", { protocolVersion: this.options.protocolVersion ?? LATEST_PROTOCOL_VERSION, capabilities, clientInfo: { name: this.options.name, version: this.options.version, ...(this.options.title === undefined ? {} : { title: this.options.title }), }, }, {}, true, ), ); if (!(SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(result.protocolVersion)) { throw new Error(`MCP server selected unsupported protocol version ${result.protocolVersion}`); } this.protocolVersionValue = result.protocolVersion; this.serverInfoValue = result.serverInfo; this.serverCapabilitiesValue = result.capabilities; this.instructionsValue = result.instructions; transport.setProtocolVersion?.(result.protocolVersion); await this.notifyInternal("notifications/initialized", undefined, true); this.state = "connected"; return result; } catch (error) { await this.close().catch(() => {}); throw error; }}instructions trong kết quả initialize là hướng dẫn của server cho cả nhóm tool; extension MCP dùng dòng đầu của nó làm mô tả server khi cấu hình không có description.const id = this.nextRequestId++;const progressToken = options.onProgress ? id : undefined;const requestParams = progressToken === undefined ? params : { ...params, _meta: { ...(isObject(params?._meta) ? params._meta : {}), progressToken } };const message: JsonRpcRequest = { jsonrpc: "2.0", id, method, ...(requestParams === undefined ? {} : { params: requestParams }),};return new Promise<unknown>((resolve, reject) => { const entry: PendingRequest = { resolve, reject, timeoutMs: options.timeoutMs ?? this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, timer: undefined, signal: options.signal, // The spec forbids cancelling `initialize`. onAbort: () => this.cancelPending( id, new McpAbortError(), method !== "initialize", String(options.signal?.reason ?? "Aborted"), ), sendController: new AbortController(), cancellable: method !== "initialize", onProgress: options.onProgress, progressToken, }; this.pending.set(id, entry); if (progressToken !== undefined) this.progressRequests.set(progressToken, id); options.signal?.addEventListener("abort", entry.onAbort, { once: true }); this.armTimeout(id, entry); transport .send(message, { signal: entry.sendController.signal }) .catch((error) => this.cancelPending(id, error, false));});notifications/cancelled cho server, trừ initialize mà đặc tả cấm huỷ. Timeout mặc định của gói là 30 giây (coding agent đặt 60 giây, hoặc timeout của server trong mcp.json), và mỗi thông báo progress của server đặt lại nó (client.ts:536–559), nên một tool chạy lâu mà vẫn báo tiến độ không bị cắt.Kết quả tools/call được kiểm tra nới tay: content theo đặc tả là bắt buộc nhưng có server chỉ trả structuredContent, và từ bản đặc tả 2026-07-28 structuredContent có thể là mọi giá trị JSON, kể cả mảng và null (client.ts:144–153). Lỗi của tool MCP không phải lỗi giao thức: chúng nằm trong kết quả với isError: true.
stdio
StdioTransport chạy server như một tiến trình con, gửi mỗi message một dòng JSON và đọc stdout theo dòng, mỗi message tối đa 16 MiB (stdio.ts:181–207). Server được đặt trong process group riêng, vì server hay được chạy qua npx hay uvx: giết tiến trình con trực tiếp sẽ để lại server thật chạy mồ côi. Đóng theo đúng đặc tả:
async close(): Promise<void> { if (this.closed) return; this.closed = true; const child = this.child; if (!child) { this.emitClose(); return; } if (child.exitCode !== null || child.signalCode !== null) { child.stdin?.end(); return; } // Shutdown per the spec: close stdin and let the server exit, then SIGTERM, then SIGKILL. const closeTimeoutMs = this.options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS; await new Promise<void>((resolve) => { const timers: ReturnType<typeof setTimeout>[] = []; child.once("close", () => { for (const timer of timers) clearTimeout(timer); // Children of the server that ignored stdin closing would otherwise outlive it. killProcessTree(child, "SIGTERM"); resolve(); }); const grace = Math.min(STDIN_CLOSE_GRACE_MS, closeTimeoutMs); timers.push(setTimeout(() => killProcessTree(child, "SIGTERM"), grace)); timers.push(setTimeout(() => killProcessTree(child, "SIGKILL"), grace + closeTimeoutMs)); child.stdin?.end(); });}exit của tiến trình còn gửi SIGTERM cho mọi nhóm tiến trình đang sống nếu pi thoát mà không kịp đóng (stdio.ts:43–53).Streamable HTTP
Mỗi message JSON-RPC là một POST. Server trả lời bằng JSON hoặc bằng một luồng SSE:
async send(message: JsonRpcMessage, { signal }: McpTransportSendOptions = {}): Promise<void> { if (!this.started || this.closed) throw new McpConnectionClosedError(); const response = await this.authorizedFetch( "POST", { headers: { accept: "application/json, text/event-stream", "content-type": "application/json" }, body: JSON.stringify(message), }, signal, ); await this.checkResponse(response); this.captureSession(response); if (!isJsonRpcRequest(message)) { // Notifications and responses are acknowledged with 202 and carry no reply; ignore any body. await discard(response); // The server-to-client stream may only open once the session is initialized. if ("method" in message && message.method === "notifications/initialized") this.startGetStream(); return; } if (response.status === 202 || response.status === 204) { throw new McpHttpError(response.status, `MCP server accepted request ${message.method} without a response`); } const type = contentType(response); if (type === "application/json") { const body: unknown = await response.json(); for (const item of Array.isArray(body) ? body : [body]) this.emitMessage(parseJsonRpcMessage(item)); return; } if (type === "text/event-stream" && response.body) { void this.consumeResponseStream(response.body, message.id); return; } await discard(response); throw new McpHttpError(response.status, `Unsupported MCP response content type: ${type ?? "missing"}`);}notifications/initialized, và được mở lại với backoff khi rớt (streamable-http.ts:428–454). Luồng SSE trả lời một request mà đứt trước khi có câu trả lời thì được nối lại bằng GET với Last-Event-ID, nếu server có gán id cho sự kiện (:377–420).Mọi fetch đi qua authorizedFetch(): gắn Mcp-Session-Id, MCP-Protocol-Version và token, và khi gặp 401 hay 403 kèm insufficient_scope thì giao cho auth provider đúng một lần rồi thử lại (streamable-http.ts:287–317). Một chi tiết nhỏ cho thấy gói được viết cho nhiều runtime: fetch được gọi không có receiver, vì Cloudflare Workers từ chối fetch của nền tảng khi this là một object khác (“Illegal invocation”, :211–214).
pi-mcp: OAuth#
Server MCP từ xa thường được bảo vệ bằng OAuth: client phải tìm ra authorization server, tự định danh với nó, đưa người dùng qua trình duyệt để lấy mã, rồi đổi mã lấy token. Phần OAuth của pi-mcp được chuyển thể từ SDK TypeScript chính thức bản 1.29.0 (giấy phép MIT, README.md:112), bỏ Zod và các shim CORS, và bắt buộc kiểm tra issuer của authorization server (oauth/discovery.ts:1–5).
- 401Một request bị từ chối. Auth provider thử refresh token; không được thì ném
McpOAuthAuthorizationRequiredErrorvà kết nối chuyển sang trạng thái “needs sign-in”. Kết nối không bao giờ tự mở trình duyệt. - DiscoveryĐọc protected resource metadata (RFC 9728) của server, lấy authorization server đầu tiên, rồi đọc metadata của nó (RFC 8414 hoặc OpenID Connect) và đối chiếu
issuer. Cóoauth.authServerMetadataUrlthì dùng thẳng tài liệu đó (discovery.ts:148–194). - Định danh clientThông tin client đã lưu; nếu không có thì một Client ID Metadata Document (CIMD); nếu không thì đăng ký động (dynamic client registration).
- Uỷ quyềnURL uỷ quyền có PKCE S256,
statevàresource(flow.ts:170–200). Người dùng duyệt trong trình duyệt; callback về một server loopback, hoặc người dùng dán URL chuyển hướng khi trình duyệt ở máy khác. - Đổi mãKiểm tra
isstheo RFC 9207, đổi mã lấy token, lưu token kèm scope đã cấp.
export async function discoverAuthorizationServerMetadata( authorizationServerUrl: string | URL, options: { fetch?: McpFetch; protocolVersion?: string; skipIssuerValidation?: boolean; signal?: AbortSignal } = {},): Promise<AuthorizationServerMetadata | undefined> { const fetch = options.fetch ?? globalThis.fetch; for (const { url } of buildAuthorizationServerDiscoveryUrls(authorizationServerUrl)) { const response = await fetchMetadata( url, fetch, options.protocolVersion ?? LATEST_PROTOCOL_VERSION, options.signal, ); if (!response.ok) { discard(response); if (isDiscoveryMiss(response.status)) continue; throw new Error(`HTTP ${response.status} loading authorization server metadata from ${url}`); } const metadata = parseAuthorizationServerMetadata(await response.json()); if (!options.skipIssuerValidation) { const expected = String(authorizationServerUrl); // URL parsing adds a trailing slash to bare origins, so compare without one on either side. const trim = (value: string) => (value.endsWith("/") ? value.slice(0, -1) : value); if (trim(metadata.issuer) !== trim(expected)) throw new OAuthIssuerMismatchError(expected, metadata.issuer); } return metadata; } return undefined;}/.well-known/oauth-authorization-server, rồi openid-configuration, :106–117). Issuer khác URL mong đợi thì ném OAuthIssuerMismatchError.const stored = await provider.clientInformation();const clientDocument = stored ? undefined : provider.clientMetadataDocument?.(metadata);if (clientDocument) { const url = new URL(clientDocument.url); if (url.protocol !== "https:" || url.pathname === "/") throw new Error("Invalid OAuth client metadata URL");}let client = stored ?? (clientDocument && { client_id: clientDocument.url });if (!client) { if (options.authorizationCode) throw new Error("OAuth client information is missing during code exchange"); if (!provider.saveClientInformation) throw new Error("OAuth client information cannot be persisted"); client = await registerClient(discovered.authorizationServerUrl, { metadata, clientMetadata: provider.clientMetadata, scope, fetch: options.fetch, signal: options.signal, }); await provider.saveClientInformation(client);}application_type suy từ redirect URI: native cho loopback và scheme tuỳ biến, không thì web (MCP SEP-837). Thiếu trường này, authorization server OpenID Connect mặc định là web và từ chối redirect URI http loopback (flow.ts:109–120).if (options.authorizationCode) { // RFC 9207: never send a code from another authorization server to this one. Without metadata there // is no issuer to compare with, so any `iss` is rejected. const iss = options.iss; if (iss !== undefined || metadata?.authorization_response_iss_parameter_supported) { if (iss !== metadata?.issuer) throw new OAuthIssuerMismatchError(metadata?.issuer, iss); } const tokens = await exchangeAuthorizationCode(discovered.authorizationServerUrl, { ...tokenOptions, code: options.authorizationCode, codeVerifier: await provider.codeVerifier(), redirectUrl, }); // A response without `scope` grants the requested scope (RFC 6749 §5.1). Recorded so a step-up can // keep it. Callers pass the options of the authorization request, so `scope` is what was requested. await provider.saveTokens(withScope(tokens, scope)); return "AUTHORIZED";iss, hoặc metadata hứa sẽ có, thì nó phải khớp issuer. Không có metadata thì không có gì để so, nên mọi iss đều bị từ chối; lỗ hổng này được vá ở 1.2.0 (mcp/CHANGELOG.md:15).Khi server đòi thêm scope (insufficient_scope), lần uỷ quyền mới xin scope bị thiếu cộng scope đã có, vì challenge có thể chỉ liệt kê phần thiếu, và một token chỉ có phần đó sẽ mất quyền mà token cũ có (flow.ts:309–318). adaptOAuthProvider() cho nhiều 401 đồng thời dùng chung một lần làm mới, và chỉ thử lại request nếu token của nó đã được thay: với refresh token xoay vòng, lần làm mới thứ hai bằng refresh token cũ sẽ thất bại và huỷ luôn grant mới (flow.ts:446–482).
Phía coding agent: credential và CIMD
Extension MCP lưu trạng thái OAuth trong ~/.pi/agent/mcp-auth.json, theo khoá là tên namespace cộng URL, để hai server cùng URL dưới hai tên đăng nhập hai tài khoản khác nhau (1.0.0):
/** * Keys of a server's state: by name and URL, so servers sharing a URL keep separate accounts, and the * legacy key by URL alone, written by older versions. */function storeKeys(name: string, serverUrl: string): { key: string; legacyKey: string } { const legacyKey = String(new URL(serverUrl)); return { key: `${mcpNamespace(name)}|${legacyKey}`, legacyKey };}Với "clientRegistration": "cimd" (1.0.1), pi không đăng ký mà tự định danh bằng một tài liệu metadata trên pi.dev. Tài liệu và redirect URI có thể phải riêng cho từng server:
/** * pi's Client ID Metadata Document, for `clientRegistration: "cimd"`, chosen like Codex chooses its own. * The configuration ensures the default callback path. Without the `iss` parameter in authorization * responses (RFC 9207), the redirect URI and the document are specific to the MCP server, so a response * cannot be mixed up with one from another authorization server (RFC 9700 section 4.4.2.2). */function clientMetadataDocument( serverUrl: string, redirectUrl: string, metadata: AuthorizationServerMetadata | undefined,): OAuthClientMetadataDocument { if ( !metadata?.client_id_metadata_document_supported || !metadata.token_endpoint_auth_methods_supported?.includes("none") ) { throw new Error( 'The authorization server does not support Client ID Metadata Documents for public clients; remove oauth.clientRegistration "cimd"', ); } if (metadata.authorization_response_iss_parameter_supported) { return { url: `${CLIENT_METADATA_BASE_URL}/client.json`, redirectUrl }; } const id = callbackId(serverUrl); const redirect = new URL(redirectUrl); redirect.pathname = `${CALLBACK_PATH}/${id}`; return { url: `${CLIENT_METADATA_BASE_URL}/${id}/client.json`, redirectUrl: redirect.href };}iss trong phản hồi uỷ quyền, pi dùng tài liệu https://pi.dev/oauth/<id>/client.json và đường callback /callback/<id> riêng cho server MCP đó, để một phản hồi không thể bị nhầm với phản hồi của authorization server khác (RFC 9700 mục 4.4.2.2). Callback server từ chối phản hồi đến sai đường (callback.ts:89–102).Toàn bộ quá trình đăng nhập, gồm chọn cổng callback giữ đúng redirect URI đã đăng ký, chờ callback hoặc URL dán vào, giới hạn 15 giây cho mỗi request tới authorization server, nằm trong signInMcpServer() (oauth.ts:468–558).
Extension MCP có sẵn#
Cấu hình
Server đến từ ~/.pi/agent/mcp.json và, khi dự án đã được tin cậy (project trust), từ .pi/mcp.json; định dạng mcpServers giống các client MCP khác nên cấu hình chép sang được (mcp/config.ts:1–30). Entry của dự án cùng tên thay entry toàn cục, trừ khi nó không có command, url hay type: khi đó nó chỉ ghi đè ba khoá (1.0.1):
for (const [name, value] of Object.entries(parsed.mcpServers ?? {})) { if (scope === "project" && isRecord(value) && isOverride(value)) { const base = servers.get(name); const extra = Object.keys(value).filter((key) => !OVERRIDE_KEYS.includes(key)); if (!base) { errors.push(`${path}: server "${name}" needs "command" or "url", or a global server to override`); } else if (extra.length > 0) { errors.push(`${path}: server "${name}": an override can only set ${OVERRIDE_KEYS.join(", ")}`); } else { const config = validateMcpServerConfig(name, { ...base.config, ...value }); if (typeof config === "string") errors.push(`${path}: ${config}`); else servers.set(name, { ...base, config, override: path }); } continue; }"auth": { "provider": ... }, gửi token của một provider đã /login làm bearer token, bị cấm trong file của dự án, để một repository không chọn được nơi credential của bạn bị gửi tới (config.ts:134–137).Exposure của server MCP có bốn giá trị, mặc định codemode:
/** * - `codemode`: tools are callable from codemode scripts but neither declared to the model nor * listed in the codemode description, which lists only the server's namespace. Scripts find them * with `searchTools()`. `codemode-deferred` is accepted as an alias. * - `deferred`: not declared to the model until the `tool_search` tool loads them; the model then * calls them directly. Does not need codemode. * - `direct`: tools are declared to the model like any other tool (and callable from codemode). * - `hidden`: tools are registered but unreachable. */export type McpExposure = "codemode" | "deferred" | "direct" | "hidden";ToolExposure của pi: codemode và deferred đều thành deferred, chỉ khác ở chỗ extension bật codemode hay tool_search để với tới chúng (mcp/tools.ts:40–46). toolExposure ghi đè cho từng tool, theo tên chính xác hoặc mẫu * (mcp-servers.ts:233–242).Tên tool và đăng ký
/** * `mcp__<server>__<tool>`, sanitized and shortened with a hash suffix when too long. Like Codex, * everything but `[A-Za-z0-9_]` becomes `_`, so the name is also the identifier codemode scripts * call it by. `isTaken` reports names used by a different MCP tool: sanitizing can map two tools to * one name (`a-b` and `a_b`), which then get the hash suffix. */export function createMcpToolName( server: string, tool: string, isTaken: (name: string) => boolean = () => false,): string { const name = `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_]/g, "_"); if (name.length <= MAX_TOOL_NAME_LENGTH && !isTaken(name)) return name; const hash = createHash("sha256").update(`${server}\0${tool}`).digest("hex").slice(0, 8); return `${name.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;}[A-Za-z0-9_] thành _, như Codex, nên tên tool cũng chính là identifier mà script gọi. Tên dài quá 64 ký tự, hoặc trùng sau khi làm sạch (read-file và read_file), được thêm hậu tố hash. Server có tên chỉ khác nhau ở - và _ bị từ chối, vì chúng sẽ chung một namespace (config.ts:128–133).const registerTools = (connection: McpServerConnection) => { const server = connection.entry.name; const entry = findServer(server)?.entry ?? connection.entry; const description = entry.config.description?.trim(); const namespace = { name: mcpNamespace(server), // `describeNamespace("docs")` finds `mcp__docs`. aliases: [server], ...(description ? { description } : {}), ...(connection.instructions ? { instructions: connection.instructions } : {}), }; for (const tool of connection.tools) { const definition = createMcpToolDefinition({ server, tool, name: assignName(tool.name, `${server}\0${tool.name}`), exposure: getMcpToolExposure(entry.config, tool.name), namespace, definitions.set(definition.name, definition); pi.registerTool(definition); } serverTools.set(server, current); // Tools cannot be unregistered, so tools the server dropped are re-registered as hidden. When // the server offers them again they are registered with their configured exposure above. for (const name of previous) { const definition = definitions.get(name); if (!current.has(name) && definition) pi.registerTool({ ...definition, exposure: "hidden" }); }describeNamespace("docs") tìm được mcp__docs, và mang instructions của server. Tool mà server bỏ đi (sau notifications/tools/list_changed) được đăng ký lại với exposure hidden.Kết quả MCP được dịch cho hai người đọc khác nhau:
/** Convert an MCP result. `isError` results become error results that keep the structured result. */export async function convertMcpResult( server: string, tool: string, result: CallToolResult, options: ConvertMcpResultOptions = {},): Promise<AgentToolResult<McpToolDetails>> { // Without content blocks, toLlmContent falls back to the structured content as JSON. const converted: (TextContent | ImageContent)[] = result.content.length > 0 ? await toModelContent(server, result.content, options) : toLlmContent(result); if (result.isError && textOf(converted) === "") { converted.push({ type: "text", text: `MCP tool ${server}/${tool} returned an error` }); } const { content, fullOutputPath } = await limitMcpContent(converted, options.saveOutput); const { _meta: _ignored, ...scriptResult } = result; return { content, details: { server, tool, ...(fullOutputPath ? { fullOutputPath } : {}) }, structuredContent: scriptResult as unknown as JsonValue, ...(result.isError ? { isError: true } : {}), };}CallToolResult trừ _meta, không bị cắt, làm structuredContent. isError là lỗi với mô hình, nhưng trong script lời gọi vẫn resolve.Kết nối nền và chờ đúng server
Khi session bắt đầu, extension đọc cấu hình, bật codemode hoặc tool_search từ cấu hình (trước khi server nào kết nối), rồi chỉ nạp client MCP sau một vòng event loop, để lần vẽ đầu không phải chờ, và kết nối mọi server ở nền (mcp/index.ts:1114–1151). Prompt đầu chỉ chờ, tối đa 10 giây, những server có tool direct, vì tool của chúng phải được khai báo trong request đầu (:1153–1174). Các server khác được chờ đúng lúc cần, nhờ reachesTools:
// A tool that reaches other tools (`reachesTools`) waits for the servers it may reach, so their// tools are registered before it runs: a codemode script for the servers it names, or for every// server when it searches or enumerates tools, and tool_search for every server. The resource// tools reach every server, so they wait for all of them. Direct MCP calls wait only for their// own server, including a reconnect still closing its old transport.pi.on("tool_call", async (event, ctx) => { const tool = pi.getAllTools().find((candidate) => candidate.name === event.toolName); if (!tool) return; // Readiness can be pending while the display state is still connected: reconnect first // closes the old transport, then opens a new one. Settled promises return immediately. const readyServers = servers.filter((server) => isEnabled(server) && server.ready); if (readyServers.length === 0) return; let waiting: McpServer[] = []; const reach = tool.reachesTools?.(event.input); if (reach !== undefined) { waiting = readyServers.filter((server) => reachesServer(reach, server.entry.name)); } else if (RESOURCE_TOOL_NAMES.has(tool.name)) { waiting = readyServers; } else { const owner = toolOwners.get(event.toolName)?.split("\0", 1)[0]; waiting = readyServers.filter((server) => server.entry.name === owner); } await waitForServers(waiting, ctx.signal);});tool_search và tool resource chờ mọi server; lời gọi MCP trực tiếp chỉ chờ server của nó.Mỗi server là một McpServerConnection, kết nối lại lười biếng khi một lời gọi thấy kết nối đã mất:
private async connectOnce(): Promise<McpClient> { const client = new McpClient({ name: "pi", version: VERSION, requestTimeoutMs: this.timeoutMs, roots: [{ uri: pathToFileURL(this.cwd).href, name: basename(this.cwd) }], }); try { transport = this.createTransport(this.entry, this.cwd, this.authProvider); await client.connect(transport); client.onNotification("notifications/tools/list_changed", () => { void this.refreshTools(client); }); const stdio = transport instanceof StdioTransport ? transport : undefined; client.onClose(() => this.handleClientClose(client, stdio)); // Servers without the tools capability (prompts or resources only) do not answer tools/list. const tools = client.serverCapabilities?.tools ? await client.listTools() : []; if (this.closed) throw new Error("shut down while connecting"); if (client.connectionState !== "connected") throw new Error("connection closed during setup"); this.client = client; this.tools = tools; this.hasResources = client.serverCapabilities?.resources !== undefined; this.instructions = client.instructions?.trim() || undefined; this.state = "connected"; this.error = undefined; this.onTools(this); this.changed(); return client;tools không bị hỏi tools/list; resource không được liệt kê lúc kết nối, chỉ ghi nhận server có resource, vì liệt kê hết có thể rất lâu (1.2.0). Lời gọi tool không được thử lại sau lỗi HTTP tạm thời vì server có thể đã chạy nó; đọc resource thì được thử lại một lần, và session hết hạn (404) thì thử lại một lần trên session mới (runtime.ts:274–301).Mục prompt, resource, dòng lệnh
Server có tool codemode hoặc deferred được liệt kê trong mục mcp_servers của system prompt, mỗi server một dòng gồm namespace, cách với tới và một câu tóm tắt, cả mục tối đa 4.096 ký tự (mcp/index.ts:155–221). Đây là nơi mô hình biết các server tồn tại mà không phải tìm, vì cả mô tả codemode lẫn tool_search đều không liệt kê chúng. Server có resource được với tới qua ba tool mang đúng tên mà Codex và opencode dùng, list_mcp_resources, list_mcp_resource_templates và read_mcp_resource, để mô hình đã được huấn luyện trên chúng dùng nguyên (resources.ts:1–11).
pi mcp add|remove|list|login|logout chạy ngoài session, không nạp extension. Đầu file ghi rõ đối tượng: agent chạy nó qua bash để cấu hình server, kiểm tra một mcp.json vừa viết và khởi động đăng nhập OAuth, còn người dùng chỉ việc duyệt trong trình duyệt (mcp/cli.ts:1–6). pi mcp list thoát với mã 1 khi có entry sai hoặc server không kết nối được. Session đang chạy nhận credential mới ở lượt kế tiếp, vì hook turn_start so token đã lưu với lúc server cần đăng nhập (mcp/index.ts:1211–1214). Extension cũng thêm server được bằng pi.registerMcpServer(); core chỉ kiểm tra và lưu đăng ký, việc kết nối thuộc về extension MCP (mcp-servers.ts:1–6).
Tool search#
Tool deferred tồn tại nhưng mô hình không biết tên chúng. Có hai cách tìm, dùng chung một bộ xếp hạng: searchTools() trong script codemode, và tool tool_search cho mô hình gọi tool trực tiếp. Văn bản được đánh chỉ mục cho mỗi tool gồm tên, tên với _ thay bằng dấu cách, mô tả, mô tả và tên thuộc tính trong schema, cùng tên, mô tả và instructions của namespace:
/** * Search text of a tool: the name, the name with `_` * as spaces, the description, schema descriptions and property names, and the namespace with its * description and instructions. */export function createToolSearchDocument( tool: Pick<ToolInfo, "name" | "description" | "parameters">, namespace?: ToolNamespace,): ToolSearchDocument { const parts = [tool.name, tool.name.replaceAll("_", " "), tool.description]; schemaText(tool.parameters, parts); if (namespace) parts.push(namespace.name, namespace.description ?? "", namespace.instructions ?? ""); return { name: tool.name, text: parts.filter((part) => part.trim()).join(" ") };}description của một server MCP trong mcp.json còn có tác dụng xếp hạng tool của nó. Từ được tách theo ranh giới camelCase và mọi ký tự ngoài chữ cái và chữ số, bỏ stop word, đưa về số ít một cách đơn giản (tool-search/tool.ts:63–80)./** Okapi BM25 with the usual parameters. Ties keep document order. */export class Bm25Ranker implements ToolRanker { private readonly k1: number; private readonly b: number; constructor(options: { k1?: number; b?: number } = {}) { this.k1 = options.k1 ?? 1.2; this.b = options.b ?? 0.75; } rank(query: string, documents: readonly ToolSearchDocument[], limit: number): ToolSearchMatch[] { const queryTerms = [...new Set(tokenize(query))]; if (queryTerms.length === 0 || documents.length === 0 || limit <= 0) return []; const termCounts = documents.map((document) => { const counts = new Map<string, number>(); for (const term of tokenize(document.text)) counts.set(term, (counts.get(term) ?? 0) + 1); return counts; }); const lengths = termCounts.map((counts) => [...counts.values()].reduce((sum, count) => sum + count, 0)); const averageLength = lengths.reduce((sum, length) => sum + length, 0) / documents.length || 1; const idf = new Map( queryTerms.map((term) => { const frequency = termCounts.filter((counts) => counts.has(term)).length; return [term, Math.log(1 + (documents.length - frequency + 0.5) / (frequency + 0.5))] as const; }), ); const matches: ToolSearchMatch[] = []; documents.forEach((document, index) => { let score = 0; for (const term of queryTerms) { const count = termCounts[index].get(term); if (!count) continue; const norm = this.k1 * (1 - this.b + (this.b * lengths[index]) / averageLength); score += (idf.get(term) ?? 0) * ((count * (this.k1 + 1)) / (count + norm)); } if (score > 0) matches.push({ name: document.name, score }); }); return matches.sort((a, b) => b.score - a.score).slice(0, limit); }}ToolRanker ghi rõ đây là bản hiện tại: một bộ xếp hạng lai có embedding có thể thay nó (:34–37).tool_search chỉ xếp hạng tool có thể tìm (exposure codemode hoặc deferred) mà chưa bật, rồi bật các kết quả:
/** * Rank the searchable tools that are not active yet and activate the matches, so the next model * call declares them. Activation is recorded in the transcript like any tool change. */function searchAndLoad( tools: NonNullable<ToolSearchToolOptions["tools"]>, query: string, limit: number,): ToolSearchResultTool[] { const active = tools.getActiveTools(); const candidates = tools.getAllTools().filter((tool) => isSearchable(tool.exposure) && !active.includes(tool.name)); const documents = candidates.map((tool) => createToolSearchDocument(tool, tool.namespace)); const matches = new Bm25Ranker().rank(query, documents, limit); if (matches.length > 0) tools.setActiveTools([...active, ...matches.map((match) => match.name)]); return matches.map((match) => ({ name: match.name, description: candidates.find((tool) => tool.name === match.name)?.description ?? "", }));}model-only và reachesTools: () => "all" (:217–244, index.ts:12–16). Nó tắt theo mặc định; extension MCP bật nó khi có server dùng exposure deferred, hoặc người dùng thêm "+tool_search" vào defaultTools (docs/cli.md:180–182).Vì sao cách nạp này giữ được prompt cache. Bài “Prompt Caching In Agents” chỉ ra rằng tên, mô tả và schema của tool là đầu vào của mô hình, nằm ở phần đầu prompt; thêm, bớt, sửa hay đổi thứ tự tool là sửa phần đầu, và trên hầu hết mô hình điều đó làm mất cache của cả hội thoại phía sau. Chỉ thay đổi thuần cộng, được neo vào một vị trí trong transcript, mới giữ được prefix (Prompt Caching In Agents). Thiết kế của tool_search làm đúng như vậy ở bốn điểm:
- Nạp là bậtKhông có cơ chế riêng: sau
setActiveTools(), ở lượt kế tiếp vòng lặp nối một system messagetoolsAddedsau kết quả củatool_search, và pi-ai gửi nó tại chỗ cho provider hỗ trợ (trace ở chương Pi 1.0). - Mô tả không đổiMô tả
tool_searchkhông liệt kê tool hay server nào, nên nó giống hệt nhau trước và sau khi server kết nối hay tool được nạp (tool-search/tool.ts:211–215). - Codemode không bị khai báo lạiMô tả
codemodeliệt kê theo exposure chứ không theo bộ đang bật, nên một lần nạp không làm đổi khai báo củacodemode(codemode/tool.ts:317–318). - Nạp một lần cho cả nhánhLần nạp nằm trong transcript, nên sau resume,
/treehay fork, tool vẫn được khai báo trên nhánh đó mà không phải tìm lại (docs/mcp.md:226).
Với provider chỉ ở mức "none", như Google, một lần nạp vẫn viết lại danh sách tool và làm trượt cache. Đó là giới hạn của API, không phải của thiết kế; trên các provider ấy, script codemode gọi tool deferred là cách duy nhất không đổi khai báo, vì lời gọi trong script không cần tool đang bật.
Trace: một script gọi song song hai tool MCP#
Kịch bản: hai server MCP linear và github, exposure mặc định codemode, đã kết nối. Từ một lần chạy describeNamespace() trước, mô hình biết tên hai tool cần dùng. Người dùng hỏi: “Có issue auth nào đang mở, và có PR nào liên quan không?”. Tên tool, đối số và hình dạng structuredContent dưới đây là giả định: chúng do server MCP định nghĩa, không phải do pi.
Ví dụ minh hoạ: script mô hình gửi trong đối số code của tool codemode
// @options: {"timeout_ms": 60000}
const [issues, prs] = await Promise.allSettled([
tools.mcp__linear__list_issues({ label: "auth", state: "open" }),
tools.mcp__github__search_pull_requests({ query: "auth is:open" }),
]);
const lines = [];
if (issues.status === "fulfilled" && !issues.value.isError) {
for (const issue of issues.value.structuredContent.issues) lines.push(`${issue.identifier}: ${issue.title}`);
} else {
lines.push(`Linear lỗi: ${issues.status === "rejected" ? issues.reason.message : "isError"}`);
}
if (prs.status === "fulfilled" && !prs.value.isError) {
lines.push(`${prs.value.structuredContent.total_count} PR liên quan`);
}
return lines.join("\n");
await, nên chúng chạy song song. Kết quả MCP có isError vẫn resolve trong script, nên script tự kiểm tra.- Lời gọi
codemodeTrên mô hình OpenAI hỗ trợ tool ngữ pháp, script đến như văn bản thô theo ngữ pháp Lark; ở provider khác nó là chuỗi trong{ code }. Vòng lặp kiểm tra đối số rồi gọi hooktool_callnhư mọi lời gọi của mô hình. - Chờ server
reachesTools({ code })không thấy global khám phá nên trả các từ của script;mcp__linear__list_issueschứamcp__linearvàmcp__github__search_pull_requestschứamcp__github, nên hook của extension MCP chờ đúng hai server đó (mcp/index.ts:227–231). Chúng đã kết nối, nên promise đã settle trả về ngay. executeCodemode()Tách dòng options (timeout_ms60.000), lấy các tool gọi được từctx.tools. Hai tool MCP có trong đó dù không được khai báo, vì exposuredeferredluôn gọi được (agent-session.ts:1582). Xin một chỗ worker, tạoCodemodeSandboxvới giới hạn 256 MB, đọc store từ nhánh.- Worker và VM
workerLauncher()tạo cờ ngắt và worker; worker gọistartVm(), chạy prelude (đóng băng built-in, dựngtools), bọc script vào hàm async và chạy. - Hai message
callMỗitools.*(...)chuyển đối số thành JSON, lấy id 1 và 2, gọibridge("call", ...); worker chuyển chúng thành{ type: "call", id, target: "tool", name, args }. Script chờ ởPromise.allSettled;stalled()thấy hai lời gọi đang chờ nên không kết thúc script. handleCall()chạy song songHost nhận hai message và chạy haihandleCall()mà không chờ nhau. Mỗi cái ghi một dòngCodemodeNestedCalltrạng tháirunning(giao diện hiện ngay) rồi gọictx.executeTool(name, args, { signal }).- Pipeline tool
NestedToolCallRunnerđặt id<id lời gọi codemode>/1và/2, pháttool_execution_startcóparentToolCallId. Không lời gọi nào phải xếp hàng độc quyền (agent chạy tool song song, tool MCP không khai báosequential).runToolCall()kiểm tra đối số theoinputSchemacủa tool MCP và chạy hook; một extension phân quyền ở đây thấy cả hai lời gọi kèmparentToolCallId. - Tới server
execute()của tool MCP lấyMcpServerConnectionvà gọiclient.callTool()với timeout của server và callback progress. Hai requesttools/callđi qua hai transport khác nhau, mỗi request có id JSON-RPC riêng. - Kết quả về
convertMcpResult()tạo nội dung cho mô hình (không ai dùng ở đây) vàstructuredContentlà cảCallToolResult.afterToolCallchạy hooktool_result. Runner ghi bản ghi, pháttool_execution_end.toScriptValue()trảstructuredContentvì tool MCP luôn có output schema. - Trở lại VMHost gửi
{ type: "result", id, ok: true, payload: JSON }; worker gọisettle(), promise trong VM resolve, VM chạy các job tới khi script chờ lại hoặc xong. Khi cả hai đã về, script dựnglinesvàreturn. - Kết thúcPrelude báo
donevới giá trị trả về dạng JSON và các lần ghi store (ở đây không có).finish()yêu cầu worker dừng: bật cờ ngắt,terminate(); chỗ worker được trả khi worker thoát. - Kết quả tool
executeCodemode()nối giá trị trả về như một item text, kiểm tra trần 10.000 token, thêm dòng đầuScript completed.AgentSessiongắnnestedCallsvào message kết quả lúc nó bắt đầu được phát, rồi lưu vào session.
Ví dụ minh hoạ: thứ mô hình nhận ở request sau toolResult codemode: Script completed Wall time 1.2 seconds Output: ENG-412: Refresh token bị mất khi huỷ request ENG-430: Đăng nhập treo khi cổng callback bận 2 PR liên quan
Ví dụ minh hoạ: bản ghi lưu trong session
"nestedCalls": {
"calls": [
{ "id": "call_7/1", "name": "mcp__linear__list_issues",
"arguments": { "label": "auth", "state": "open" },
"status": "ok", "durationMs": 412 },
{ "id": "call_7/2", "name": "mcp__github__search_pull_requests",
"arguments": { "query": "auth is:open" },
"status": "ok", "durationMs": 655 } ],
"complete": true }
Nếu kết nối tới server GitHub đứt giữa chừng, McpClient từ chối request đang chờ và execute() của tool MCP ném lỗi, runToolCall() biến nó thành kết quả lỗi không có structuredContent, toScriptValue() ném Error với văn bản lỗi, và trong script prs.status là "rejected". Script vẫn trả về các dòng của Linear; lời gọi codemode vẫn thành công; bản ghi của lời gọi thứ hai mang status: "error" và error đã cắt ngắn. Mô hình thấy đúng những gì script quyết định nói về lỗi đó.
Quyết định thiết kế#
Script chạy ở phía harness, trong một VM riêng. Phương án hiển nhiên là để mô hình viết script Node rồi chạy bằng bash. Nhưng khi đó tool của pi, server MCP đã đăng nhập, classifier với credential của session đều nằm ngoài tầm với, hoặc phải được phơi ra qua mạng. Codemode đặt script cạnh vòng lặp, nơi các thứ đó đã có, và bọc nó bằng một VM QuickJS mà khả năng duy nhất là gọi tool qua một cầu nối. Pi không có hệ thống phân quyền (chương Lịch sử); VM không thêm quyền gì, nó chỉ bảo đảm script không đi đường nào khác ngoài tool, và mọi lời gọi tool đi qua đúng pipeline có hook như lời gọi của mô hình.
Mỗi lần chạy một worker mới. Dựng worker và VM tốn khoảng 20 ms mỗi lần; dùng lại VM sẽ tiết kiệm khoản đó, nhưng một script quay vô hạn hay phá trạng thái sẽ đầu độc lần chạy sau. Worker mới cho phép kết thúc bằng terminate() và không để lại gì (host.ts:12–19). Giá phải trả là giới hạn số worker sống đồng thời và cờ ngắt cho Bun.
Cầu nối chỉ chở chuỗi JSON. Không có object nào được chia sẻ giữa VM và host; prelude tự JSON.parse phía VM, host tự kiểm tra phía host và coi payload hỏng là lỗi sandbox. Đóng băng built-in và Proxy trên tools là hai lớp bảo vệ thêm cho chính prelude, không phải cho host.
Chỉ output tới mô hình. Kết quả lồng nhau không vào transcript; transcript chỉ giữ một bản ghi có giới hạn. Đây là nguồn tiết kiệm token chính của codemode, và là lý do output có ngân sách mặc định 10.000 token cùng file tạm cho phần thừa. Trạng thái giữa các lần chạy, store(), cũng đi vào session dưới dạng entry, không phải file, để nó theo đúng nhánh của cây.
Khai báo TypeScript có ngân sách, cộng tìm kiếm. Liệt kê mọi tool trong mô tả thì quay lại đúng vấn đề MCP năm 2025. Pi liệt kê trong một ngân sách, giữ mô tả ổn định, và cho script bốn cách tìm phần còn lại (ALL_TOOLS, searchTools(), describeTool(), describeNamespace()).
Client MCP tự viết. pi-mcp không phụ thuộc SDK chính thức; phần OAuth được chuyển thể từ SDK, bỏ Zod, thêm kiểm tra issuer. Gói nhỏ, chạy được trên Cloudflare Workers, và các bản vá bảo mật như RFC 9207 nằm trong code của chính dự án. Tradeoff: tự theo kịp đặc tả, và một số phần của MCP (sampling, tasks, batch) chưa có.
Mặc định là gián tiếp. Tool MCP mặc định chỉ gọi được từ script; muốn mô hình gọi thẳng thì phải chọn deferred (qua tool_search) hoặc direct. Server kết nối ở nền, và chỉ server direct mới làm prompt đầu phải chờ.
Theo quy ước của Codex khi có lợi. Tên tool mcp__server__tool được làm sạch như Codex, kết quả dài bị cắt theo định dạng của Codex, tool resource mang đúng tên của Codex và opencode, CIMD chọn tài liệu như Codex chọn. Mô hình đã được huấn luyện trên các quy ước đó thì không phải học quy ước mới.
Built-in thay được. Một extension đăng ký /mcp, codemode hay tool_search thay chỗ bản có sẵn, và extension MCP chỉ bật một codemode hay tool_search thay thế nếu nó khai báo reachesTools (docs/mcp.md:264–268). Tính năng vào lõi nhưng không khoá người dùng vào cách cài của lõi.
Cạm bẫy và trường hợp biên#
- Không có deadline mặc định. Trong coding agent, script không có
timeout_mschạy cho tới khi xong hoặc bị huỷ; một tool treo giữ script treo (docs/codemode.md:16). - Lời gọi là thật. Script lỗi giữa chừng không hoàn tác những gì đã chạy; lời gọi còn dở khi script kết thúc bị huỷ, promise không được
awaitbị bỏ (docs/codemode.md:18). - Lỗi MCP không reject. Kết quả
isErrorvẫn resolve trong script; cũng vậy vớibashcó mã thoát khác 0, vì tool có output schema trả object kể cả khi lỗi. Tool không có output schema thì reject khi lỗi. typeof tools.xném lỗi vì Proxy; dùng"x" in tools.- Store chỉ lưu khi script thành công, theo nhánh, tối đa 262.144 ký tự JSON mỗi giá trị; không dùng để giữ ảnh.
- Hai worker cho cả tiến trình. Subagent chạy trong cùng tiến trình mà cũng dùng codemode có thể phải chờ tới 60 giây rồi thất bại mà chưa chạy (docs/codemode.md:228). Trên Bun, built-in không thăm dò cờ ngắt có thể chạy tiếp sau deadline.
- Dòng
// @options:phải là dòng đầu; ở chỗ khác lời gọi thất bại trước khi script chạy. - Tên MCP bị chuẩn hoá.
-thành_; tên dài hay trùng có hậu tố hash, nên đừng đoán tên, hãy tìm. Hai server chỉ khác nhau ở-/_không cùng tồn tại được. - Cấu hình dự án cần trust, và
auth.providerchỉ được đặt trong file toàn cục. OAuth chỉ áp dụng cho server HTTP không có headerAuthorization(runtime.ts:82–87). - Lời gọi tool MCP không được thử lại sau lỗi mạng tạm thời, vì server có thể đã thực hiện nó.
- SDK không nạp built-in extension. Session dựng bằng SDK phải tự thêm extension MCP, codemode và tool-search (docs/mcp.md:270–272).
- Tài liệu và code lệch nhẹ.
docs/mcp.mdnói pi bậtcodemode“when a server withcodemodeexposure connects” (docs/mcp.md:204); ở v1.4.0, code bật nó từ cấu hình ngay khi session bắt đầu, trước khi server nào kết nối (mcp/index.ts:501–503).
Đọc code ở đâu#
| # | File | Đọc để hiểu |
|---|---|---|
| 1 | coding-agent/docs/codemode.md | Script nhìn thấy gì: global, giá trị trả về, store, models, giới hạn. Đọc trước code. |
| 2 | codemode/src/types.ts | API của gói: tool, kết quả, lỗi, các tuỳ chọn, lần chạy tất định. |
| 3 | runtime/execution.ts, protocol.ts, host.ts, worker.ts | Phía host, giao thức, worker launcher. |
| 4 | runtime/vm.ts, prelude-source.ts | VM, cầu nối, chế độ tất định; mọi thứ script chạm tới. |
| 5 | codemode/src/declarations.ts | JSON Schema thành TypeScript, tag ràng buộc. |
| 6 | inline.ts, remote.ts, cloudflare.ts | Chạy ngoài Node: ngân sách ngắt, RPC, Durable Object. |
| 7 | extensions/codemode/tool.ts | Định nghĩa tool, mô tả và ngân sách, prepareLoadout, reachesTools. |
| 8 | extensions/codemode/execute.ts | Một lần chạy: worker, tool cho sandbox, output, store, models.*, global khám phá. |
| 9 | core/nested-tool-calls.ts, agent-session.ts:726–764 | Lời gọi lồng nhau qua pipeline tool, bản ghi nestedCalls. |
| 10 | extensions/tool-search/tool.ts | BM25 và tool_search; ngắn, đọc hết. |
| 11 | mcp/README.md, mcp/src/client.ts | Phạm vi giao thức, vòng đời client, huỷ, progress. |
| 12 | transports/stdio.ts, streamable-http.ts | Đóng khung, nhóm tiến trình, SSE, nối lại, uỷ quyền. |
| 13 | oauth/discovery.ts, oauth/flow.ts | Discovery, đăng ký, PKCE, RFC 9207, step-up, làm mới dùng chung. |
| 14 | coding-agent/docs/mcp.md, core/mcp-servers.ts, extensions/mcp/config.ts | Cấu hình, exposure, kiểm tra hợp lệ, override của dự án. |
| 15 | extensions/mcp/index.ts, tools.ts, runtime.ts | Đăng ký tool, mục mcp_servers, chờ server, chuyển đổi kết quả, kết nối. |
| 16 | extensions/mcp/oauth.ts, resources.ts, cli.ts | Credential, CIMD, đăng nhập; tool resource; pi mcp. |
Tóm tắt#
- MCP vào lõi ở 0.99.0 vì hai vấn đề cũ đã có lời giải: chi phí ngữ cảnh được xử lý bằng exposure và nạp tool hoãn lại, còn việc ghép lời gọi được xử lý bằng codemode.
pi-codemodechạy script của mô hình trong một VM QuickJS/WASM mới cho mỗi lần chạy, trong một worker thread; host và VM chỉ trao đổi chuỗi JSON; prelude đóng băng built-in, dựngtoolstrên một Proxy và có trần cho output và store. Gói còn có lần chạy tất định cho Pi Durable và các sandbox cho Cloudflare.- Tool
codemodecủa coding agent chỉ dùng API chung của extension:prepareLoadoutdựng mô tả trong một ngân sách mà không bao giờ liệt kê tooldeferred,ctx.executeTool()chạy lời gọi lồng nhau qua đúng pipeline tool vớiparentToolCallId, và chỉ output của script tới mô hình. pi-mcplà client độc lập với stdio, Streamable HTTP và OAuth đã gia cố (issuer, RFC 9207, CIMD, step-up). Extension MCP kết nối ở nền, đặt tênmcp__server__tool, mặc định chỉ cho script gọi, liệt kê server trong mụcmcp_servers, và chờ đúng server mà một lời gọi cần.tool_searchxếp hạng BM25 các tool chưa khai báo và nạp chúng bằng cách bật chúng; nhờ system message nối thêm, mô tả cố định và bản ghi trong transcript, việc nạp giữ được prompt cache trên provider hỗ trợ.
16Chord: state, delta, contract và RPC#
Chương này đọc @earendil-works/chord (gọi tắt Chord), package mà Pi Durable dựa vào để giữ state của document, truyền huỷ (cancellation) và span telemetry, và ghi SQLite. Không có bài blog nào viết riêng về Chord, nên nguồn chính là tài liệu trong repo (README, docs/guide.md, docs/spec.md, docs/tutorial.md, src/delta/README.md) và code ở v1.4.0. Ta đi từ phần nền dùng được ở mọi nơi (Context, phép toán delta, tracker, state nhân bản) lên tầng host runtime (contract, object, reference, hot reload, link), lần theo một thay đổi state đi từ server qua link tới trình duyệt, rồi xem Pi Durable dùng những mảnh nào. Phần dùng các mảnh đó bên trong Durable nằm ở chương Durable: session, commit, document và storage.
Vai trò: Chord là gì và ai dùng nó#
Mô tả trong package.json là "Typed host runtime for plugins, contracts, replicated state, and RPC over JSON links" (package.json:4). README nói rõ hơn: Chord dựng ứng dụng từ các object sống trong nhiều tiến trình (một server, các worker, các tab trình duyệt) và có thể nạp lại nóng (hot reload) trong lúc đang chạy (README.md:3). Năm ý của README là khung cho cả chương:
- An **object** is state plus code at an address. It is created on demand and lives while someone references it.- Everyone gets a **reference**, never the object. References work the same locally and across processes, and survive reloads.- Every object knows its **principal**: an owner and a guest get different objects, with different methods.- **State** is observable JSON. Remote viewers get a snapshot followed by deltas.- **Hot reload** swaps code while it runs. Work in flight finishes on the code it started with, state is kept, and incompatible changes migrate at a safe point.Contracts are runtime values described with [TypeBox](https://github.com/sinclairzx81/typebox) schemas. Hosts exchange JSON frames over links: in memory, a Node IPC channel, or a WebSocket.Package có chín điểm vào (entry point), khai báo ở package.json:8–55 và mô tả ở bảng của README (README.md:13–25):
| Import | Nội dung | Chạy ở |
|---|---|---|
@earendil-works/chord | Contract (remote, local, method, opaque), host (createHost, defineFacet, defineRegistry), link, lỗi; cộng phần nền chung: Context, JsonValue, Draft, copyJson, replicatedState | Node và trình duyệt |
/context | BACKGROUND_CONTEXT, withCancel, withAbortSignal, withTelemetry, traceIn, awaitWithContext | Node và trình duyệt |
/delta | Tracker giao dịch, diff cấu trúc, apply bất biến, bộ mã hoá/giải mã lô phép toán | Node và trình duyệt |
/output | OutputBuffer, Progress, boundOutput | Node và trình duyệt |
/node | Tiến trình host con (createHostManager, forkDriver, childControl, processLock) và nodeLoader | Node |
/sqlite, /sqlite/node, /sqlite/cloudflare | Facade SQLite bất đồng bộ, migration nguyên tử, adapter node:sqlite và Durable Object | Mọi adapter SQLite |
/telemetry-sqlite | createSqliteTelemetry(): ghi span và metric vào SQLite, truy vấn lại | Mọi adapter SQLite |
Lịch sử ngắn
Commit đầu tiên, 28b49a6b3 "feat: add Chord runtime foundation" (Armin Ronacher, 2026-08-28), có phần code duy nhất là src/context.ts, tách ra từ packages/agent/src/harness/context.ts, kèm một PLANNING.md dài 859 dòng. Bản kế hoạch đó đặt mục tiêu một nền "application-neutral" cho plugin, service, RPC và state nhân bản, và cấm Chord phụ thuộc package Pi khác. State giao dịch được thêm ở 10d1ad621 (2026-09-21). Bản 1.2.0 thay toàn bộ runtime cũ (facet và service) bằng host runtime có kiểu như hiện nay, và gỡ ba package pi-client, pi-protocol, pi-server vốn chỉ phục vụ runtime cũ (CHANGELOG.md:78–81). Bản 1.3.0 đổi tên theo khái niệm (Handle thành Ref, epoch thành codeVersion) và nhận OutputBuffer từ Durable; bản 1.4.0 nhận các driver SQLite từ Durable và bắt buộc Context.telemetry (CHANGELOG.md:7–13).
README của Chord không gắn nhãn "experimental" như Pi Durable, nhưng cả ba bản minor 1.2, 1.3 và 1.4 đều có mục Breaking Changes. Hãy coi API là đang chuyển động.
Ai dùng Chord ở v1.4.0
Trong monorepo chỉ hai package khai báo phụ thuộc vào Chord: Pi Durable (durable/package.json:153) và pi-env (env/package.json:48). Coding agent, pi-agent-core và pi-tui không dùng nó. Điều quan trọng hơn: không package nào ngoài chính Chord tạo host. Tìm createHost, defineFacet, memoryLinkPair, hostNamed trong packages/ chỉ ra kết quả trong packages/chord. pi-env chỉ nhập kiểu Context (remote-env.ts:5); Pi Durable dùng Context, JSON, delta, replicatedState, output và sqlite (xem mục Pi Durable dùng Chord thế nào). Tầng host runtime (contract, object, link, reload) ở bản này được kiểm chứng bằng bộ test và tutorial của chính nó; mục "Durable" trong guide mô tả cách một ứng dụng có thể đặt Harness của Durable vào một object của Chord (guide.md:756–782), nhưng không có code nào trong repo làm việc đó.
Mô hình tư duy: hai tầng trong một package#
Chord gồm hai tầng. Tầng dưới là các mảnh độc lập, không cần host: Context, JSON nghiêm ngặt, delta, state nhân bản, output, SQLite. Tầng trên là host runtime, dựng trên các mảnh đó: contract mô tả object, host phục vụ object, reference là cách duy nhất chạm tới object, link nối các host thành cụm, và reload thay code trong lúc chạy.
Host runtime import "@earendil-works/chord" (+ "/node" cho tiến trình con)
contract (remote · local, schema TypeBox) ── facet của plugin gọi host.serve(contract, factory)
host ── object (state + code tại một địa chỉ) ◀── reference (Ref) ◀── người gọi
root + Ctx + code version · hot reload: prepare → gate → commit → retire · registry
link (memory · IPC · WebSocket) ── Connection ── frame JSON: hold · shape · call · state ...
───────────────────────────────────────────────────────────────────────────────
Nền chung dùng được không cần host; đây là phần Pi Durable dùng
Context ("/context") huỷ + span telemetry + giá trị có kiểu, truyền tường minh
delta ("/delta") tracker (draft → prepare → adopt) · 7 phép toán · diff · apply · codec
replicatedState giá trị JSON bất biến, quan sát được, đổi theo revision nguyên tử
output ("/output") OutputBuffer (cửa sổ đầu/đuôi) · Progress (nhịp commit)
sqlite ("/sqlite…") facade bất đồng bộ · migration · node:sqlite · Durable Object
telemetry-sqlite span và metric vào SQLite, truy vấn lại
Từ vựng của phần còn lại (định nghĩa gốc ở spec.md:10–23):
| Thuật ngữ | Nghĩa | Trong code |
|---|---|---|
| Context | Giá trị bất biến mang tín hiệu huỷ, span telemetry hiện tại và các giá trị có khoá; truyền tay qua mọi lời gọi bất đồng bộ | Context, context/index.ts:11 |
| Revision, lô phép toán (op batch) | Một giá trị JSON bất biến; danh sách phép toán biến revision trước thành revision này | Op, Prepared |
| Tracker, draft | Chủ của một chuỗi revision; bản nháp đột biến được (mutable) của một thay đổi đang mở | Tracker, Change, Draft |
| State nhân bản (replicated state) | Giá trị JSON quan sát được, mỗi thay đổi là một revision; bản sao ở xa nhận ảnh chụp rồi phép toán | replicatedState, Cell |
| Contract | Giá trị lúc chạy mô tả một loại object: tên, version, input (địa chỉ), member | remote(), local() |
| Facet | Code của một plugin cho một loại host (server, worker, trình duyệt) | defineFacet() |
| Object, reference | Một thực thể của loại object tại một địa chỉ (type, input, principal); thứ get trả về, không bao giờ là object | ObjectImpl, Ref |
| Principal, variant | Danh tính người gọi do ứng dụng định nghĩa; tập member một object remote mở cho principal đó | createHost({ self, principalKey }) |
| Root, Ctx, code version | Một cây lời gọi ngắn; ngữ cảnh lời gọi của nó; một bộ code đã commit trên host | Ctx, types.ts:22 |
| Unit, snap, ops, reset | Một shape kèm ảnh chụp các member state; ảnh chụp; lô phép toán; ảnh chụp mới thay chuỗi cũ | frame shape, state |
Context: huỷ, telemetry và giá trị tường minh#
Bài toán: một thao tác bất đồng bộ (một lần ghi storage, một lời gọi mô hình) cần biết ba thứ của người gọi: có bị huỷ chưa, span telemetry nào là cha của nó, và vài giá trị riêng của ứng dụng. Cách hay gặp trong Node là AsyncLocalStorage, một ngữ cảnh "vô hình" đi theo promise. Chord chọn ngược lại: một giá trị bất biến, truyền tay làm tham số cuối của mọi hàm. Không có span "hiện tại" ngầm nào (README.md:91), và spec của host runtime cũng ghi "Explicit; no AsyncLocalStorage dependency" (spec.md:196).
/** Typed identity for one value carried by a {@link Context}. */export interface ContextKey<T> { readonly token: symbol; /** Type-only marker that prevents keys with different value types from being interchangeable. */ readonly valueType?: (value: T) => T;}/** Immutable invocation-scoped values passed explicitly through operations. */export interface Context { readonly abortSignal: AbortSignal | undefined; readonly telemetry: TelemetrySpan; value<T>(key: ContextKey<T>): T | undefined; toString(): string;}symbol bọc trong object đông cứng; trường valueType chỉ tồn tại ở mức kiểu để hai khoá khác kiểu giá trị không đổi chỗ được cho nhau.Hiện thực là một danh sách liên kết: mỗi lần "dẫn xuất" tạo một nút ContextValue trỏ về cha, và value(key) đi ngược chuỗi cho tới khi gặp khoá. Tín hiệu huỷ và span cũng chỉ là hai khoá riêng. Context gốc là EmptyContext, không mang gì:
abstract class BaseContext implements Context { abstract value<T>(key: ContextKey<T>): T | undefined; abstract toString(): string; get abortSignal(): AbortSignal | undefined { return this.value(ABORT_SIGNAL_CONTEXT_KEY); } get telemetry(): TelemetrySpan { return this.value(TELEMETRY_CONTEXT_KEY) ?? NOOP_TELEMETRY_CONTEXT; }}class ContextValue<T> extends BaseContext { readonly #parent: Context; readonly #key: ContextKey<T>; readonly #value: T; constructor(parent: Context, key: ContextKey<T>, value: T) { super(); this.#parent = parent; this.#key = key; this.#value = value; } value<Value>(key: ContextKey<Value>): Value | undefined { if (key.token === this.#key.token) return this.#value as unknown as Value; return this.#parent.value(key); } toString(): string { return `${this.#parent}.WithValue(${this.#key.token.description ?? "anonymous"})`; }}export const BACKGROUND_CONTEXT: Context = new EmptyContext("[Context BACKGROUND_CONTEXT]");export const TODO_CONTEXT: Context = new EmptyContext("[Context TODO_CONTEXT]");telemetry trả về span no-op dùng chung của pi-telemetry, nên mọi Context đều gọi được telemetry.addEvent() mà không cần kiểm tra. TODO_CONTEXT là một EmptyContext khác, giống hệt BACKGROUND_CONTEXT trừ tên mà toString() in ra; code không ghi chú khi nào dùng cái nào.Các hàm dẫn xuất không bao giờ sửa cha:
/** Derive a context containing one additional or replaced value. */export function withContextValue<T>(key: ContextKey<T>, value: T, parent: Context): Context { return new ContextValue(parent, key, value);}/** Derive an explicit telemetry parent without changing cancellation or application values. */export function withTelemetry<T extends TelemetrySpan>( telemetry: T, context: Context,): Context & { readonly telemetry: T } { return withContextValue(TELEMETRY_CONTEXT_KEY, telemetry, context) as Context & { readonly telemetry: T };}/** Run a child span with a derived context whose telemetry member is that span. */export function traceIn<T>( context: Context, options: SpanOptions, fn: (context: Context) => T | Promise<T>,): Promise<T> { return context.telemetry.startSpan(options, (span) => fn(withTelemetry(span, context)));}/** * Derive a context cancelled by either the parent signal or the supplied signal. * The parent context remains unchanged. */export function withAbortSignal(signal: AbortSignal, context: Context): Context { const parentSignal = context.abortSignal; const combined = parentSignal === undefined ? signal : AbortSignal.any([parentSignal, signal]); return withContextValue(ABORT_SIGNAL_CONTEXT_KEY, combined, context);}/** Derive a context retaining all values except caller cancellation. Intended for mandatory cleanup only. */export function withoutAbortSignal(context: Context): Context { return withContextValue(ABORT_SIGNAL_CONTEXT_KEY, undefined, context);}/** Derive an independently cancellable child context. */export function withCancel(context: Context): { readonly context: Context; readonly cancel: (reason?: unknown) => void;} { const controller = new AbortController(); return { context: withAbortSignal(controller.signal, context), cancel: (reason?: unknown) => controller.abort(reason), };}traceIn mở span con trên span của Context hiện tại rồi chạy fn với Context mới mang span con đó. withAbortSignal gộp tín hiệu cha và tín hiệu mới; withoutAbortSignal giữ mọi giá trị nhưng bỏ huỷ, chỉ dành cho việc dọn dẹp bắt buộc.awaitWithContext(promise, context) chờ một promise cho tới khi nó xong hoặc Context bị huỷ; huỷ chỉ làm người chờ bị từ chối, không huỷ công việc bên dưới (context/index.ts:134–156). Đây là đúng ngữ nghĩa Pi Durable hứa: "Cancelling a wait only cancels that wait, never the work" (durable/README.md:78).
Từ 1.4.0, telemetry là trường bắt buộc; một Context tự viết phải cung cấp nó, dùng span no-op khi không bật telemetry (CHANGELOG.md:7). Adapter telemetry (thứ mở span gốc) thuộc về host và không bao giờ nằm trong Context; Context chỉ mang span (README.md:75). README cũng nói rõ giới hạn hiện tại: các helper này chưa tự đo đạc host, method hay frame mạng của Chord (README.md:93). Kiểu span đến từ pi-telemetry, xem chương pi-env, telemetry và evals.
Để đối chiếu, đây là Ctx của host runtime: nó gắn với một root (một cây lời gọi), mang code version để mọi lời gọi lồng nhau chạy cùng một phiên bản code, và tự giải phóng các reference lấy qua get khi lời gọi xong.
/** * The explicit call context of one root (a short Chord call tree). Calls made with it carry its root id and code * version; `get` references are released when the call that received this ctx settles. */export interface Ctx<P = unknown> { readonly signal: AbortSignal; readonly rootId: string; readonly codeVersion: number; /** Who the root acts as: the caller at ingress, the host's own principal inside objects. */ readonly principal: P; /** * A reference for this call, released when the call settles. Resolves once it is ready: live, with every state * member of its variant (unless `state: false`). Rejects with the activation error, `aborted` when the ctx is * aborted or the call settles first, or `unavailable/ready_timeout` after the host's `readyTimeout`; the reference * is released then. */ get<C extends AnyContract>(type: C, input: InputOf<C>, options?: GetOptions<P>): Promise<Ref<C>>;}Bảy phép toán delta và dạng dây của chúng#
Mọi thay đổi state trong Chord được mô tả bằng một lô phép toán (op batch) trên JSON. Cùng một dạng tuple dùng trong bộ nhớ, trên dây và trên đĩa: Pi Durable lưu nguyên các tuple này làm bản ghi delta của document (chương Durable storage).
export type Seg = string | number;export type Path = readonly Seg[];export type NonEmptyPath = readonly [Seg, ...Seg[]];/** A path inline, or an id assigned by the encoder on second use. */export type PathRef<P extends Path = Path> = P | number;/** * Tuples are the form — in memory, on the wire, on disk. * * `r` is the ONLY op that replaces a whole value. `s`/`d`/`a`/`t` cannot target * the root: the type forbids it. `p` and `m` may, because a tracked value can * itself be an array. Operation shape is not canonical: an array may be emptied by either * a replacement or a root splice. * * `Op` knows nothing about the path dictionary. Interning, id references and * omitted paths live in `WireOp` and exist only between `encode` and `decode`. */export type Op = | readonly ["r", JsonValue] | readonly ["s", NonEmptyPath, JsonValue] | readonly ["d", NonEmptyPath] | readonly ["a", NonEmptyPath, string] | readonly ["t", NonEmptyPath, number] | readonly ["p", Path, number, number, JsonValue[]] /** Reorder an array in place: `new[i] = old[permutation[i]]`. */ | readonly ["m", Path, number[]];s, d, a, t nhắm vào gốc (NonEmptyPath); chỉ r thay cả giá trị. p và m được nhắm vào gốc vì giá trị được theo dõi có thể là một mảng.| Tuple | Nghĩa | Ví dụ minh hoạ |
|---|---|---|
["r", value] | Thay toàn bộ giá trị | ["r", {"count": 0}] |
["s", path, value] | Gán một thuộc tính hay một phần tử mảng | ["s", ["status"], "done"] |
["d", path] | Xoá thuộc tính, hoặc bỏ một phần tử mảng | ["d", ["draft"]] |
["a", path, text] | Nối thêm vào cuối một chuỗi | ["a", ["output"], "ok\n"] |
["t", path, count] | Bỏ count đơn vị UTF-16 ở đầu chuỗi | ["t", ["output"], 1024] |
["p", path, index, remove, items] | Splice một mảng | ["p", ["rows"], 3, 0, [{"id": 4}]] |
["m", path, permutation] | Sắp lại mảng: new[i] = old[permutation[i]] | ["m", ["rows"], [2, 0, 1]] |
Bảng nghĩa gốc nằm trong delta README (delta/README.md:153–168), cùng một câu cần nhớ: "Batches are exact but not canonical". Một lô mô tả đúng thay đổi, nhưng cùng một thay đổi có thể ra các tuple khác nhau, và một tập sửa lớn có thể bị gộp thành một splice vùng, một s ở tổ tiên, hay r. Chỉ giá trị kết quả là hợp đồng; đừng suy ra nghĩa từ hình dạng phép toán (delta/README.md:94).
Dạng dây: encoder và decoder
Một luồng (stream) state đổi liên tục (văn bản mô hình đang được stream vào ["generation", "message", ...]) lặp lại cùng một đường dẫn rất nhiều lần. WireOp thêm hai cách nén và không gì khác: định nghĩa một mã số cho đường dẫn, và bỏ đường dẫn khi nó trùng phép toán ngay trước trong cùng lô (delta/index.ts:42–66).
/** * Intern on SECOND use. A definition costs more than the path it replaces, so * interning on first use loses on the many paths written exactly once. */export function encoder(): Encoder { const seen = new Set<string>(); const ids = new Map<string, number>(); let nextId = 0; let previous: string | undefined; // last path in THIS batch return { encode(ops) { // Arity omission is scoped to a batch. Letting it span batches would make // a batch's first op depend on the previous batch's last one, so a reader // that skips or reorders a batch decodes into the wrong path. Ids are the // only cross-batch state, and the dictionary makes those explicit. previous = undefined; const out: WireOp[] = []; for (const op of ops) { if (op[0] === "r") { out.push(op); // A base batch is a RECOVERY POINT: a reader replays from the last one // with a fresh decoder. So everything after it must be self-contained. // Keeping ids across a replacement emits references to definitions the // reader never saw — recovery fails with an unresolvable path id. seen.clear(); ids.clear(); nextId = 0; previous = undefined; continue; } let ref: PathRef = path; const existing = ids.get(key); if (existing !== undefined) { ref = existing; } else if (seen.has(key)) { const id = nextId++; ids.set(key, id); out.push(["#", id, path]); // second use: define, then reference ref = id; } else { seen.add(key); // first use: inline }r.Ví dụ minh hoạ (suy ra từ code ở trên, không chạy thật): một encoder, ba lô liên tiếp
lô 1 Op: ["s",["status"],"running"] ["a",["output"],"x"] ["a",["output"],"y"]
dây: ["s",["status"],"running"] ["a",["output"],"x"] ["a","y"]
lần đầu: đường dẫn viết thẳng trùng phép trước: bỏ đường dẫn
lô 2 Op: ["a",["output"],"z"] ["s",["status"],"done"]
dây: ["#",0,["output"]] ["a",0,"z"] ["#",1,["status"]] ["s",1,"done"]
lần dùng thứ hai: định nghĩa mã 0 rồi tham chiếu
lô 3 Op: ["a",["output"],"!"]
dây: ["a",0,"!"]
lô 4 Op: ["r",{"status":"idle","output":""}] → encoder xoá từ điển; lô sau định nghĩa lại từ đầu
Decoder kiểm từng phép toán theo ngữ pháp dây (assertValidWireOp), dựng lại đường dẫn, và xoá từ điển khi gặp r (delta/index.ts:666–685). Quy tắc vận hành: một cặp encoder/decoder cho mỗi luồng state độc lập; dùng chung một kết nối không biến hai state thành một luồng (delta/index.ts:547–554). Sau một lỗi giải mã, bỏ decoder và bản sao, khôi phục từ một r sau đó (delta/README.md:179–182).
Đường dẫn là dữ liệu không tin cậy
Phép toán có thể đến từ một plugin, hay từ một tool mà details lặp lại output của mô hình. Phép áp (applier) làm đúng việc parent[key] = value, nên ["s", ["__proto__", "isAdmin"], true] sẽ làm bẩn Object.prototype của cả tiến trình. Vì vậy ba đoạn __proto__, constructor, prototype bị cấm, applier ghi bằng Object.defineProperty thay vì phép gán, và chỉ đi theo thuộc tính riêng (own property) (delta/index.ts:120–131). Chỉ số mảng chỉ được trỏ vào phần tử có sẵn hoặc đúng một ô sau cuối: mảng thưa không sống sót qua JSON, và ["s", ["xs", 4294967290], 1] sẽ cấp phát một mảng hơn bốn tỉ ô từ một phép toán (delta/index.ts:289–306). Phía tracker, một thay đổi tại hoặc dưới khoá cấm được gộp thành s của tổ tiên an toàn gần nhất, hoặc r ở gốc (delta/README.md:170–177).
Tracker: draft, prepare, adopt#
Bài toán: Pi Durable sửa document pi.live mỗi 100 ms trong lúc mô hình stream. Nó cần (1) một API sửa tự nhiên (draft.output += chunk), (2) đúng lô phép toán của lần sửa đó để lưu làm delta và gửi cho người xem, (3) revision cũ vẫn dùng được trong lúc revision mới đang được ghi xuống storage, và (4) nếu storage lỗi thì bỏ thay đổi mà không động vào state đang có. Tracker giải cả bốn bằng ba bước tách rời: mở draft, chuẩn bị (prepare), nhận (adopt).
export interface Prepared<T extends object> { readonly base: T; readonly value: T; readonly ops: readonly Op[]; readonly baseRevision: number; abort(): void;}export interface Change<T extends object> { readonly state: Draft<T>; prepare(): Prepared<T>; abort(): void;}export interface Tracker<T extends object> { readonly value: T; readonly revision: number; beginChange(): Change<T>; prepareReplace(value: T): Prepared<T>; adopt(prepared: Prepared<T>): void;}Prepared mang base (revision gốc), value (revision mới) và ops biến cái này thành cái kia, cộng baseRevision để phát hiện tranh chấp.Cách dùng ngắn nhất có trong README (README.md:127–138): track(initial) nhận quyền sở hữu gốc trong O(1), beginChange() mở một draft, sửa change.state, prepare() trả về một Prepared, rồi adopt(prepared).
Draft là một lớp phủ proxy
beginChange() không sao chép gì. Nó tạo một ngữ cảnh lớp phủ (overlay) mà gốc là một Proxy trên revision hiện tại; mỗi container được đọc qua draft có một nút phủ riêng, ghi lại phép ghi, phép xoá và các thao tác mảng của chính nó. Ghi một thuộc tính đi qua setProperty:
function setProperty(node: OverlayNode, property: PropertyKey, supplied: unknown): boolean { assertWritable(node.context); if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); if (Array.isArray(nodeBase(node))) { if (property === "length") { setArrayLength(node, toArrayLength(supplied)); return true; } const index = arrayIndex(property); if (index === undefined) throw new TypeError("Only array indices and length can be written"); if (index > arrayLength(arrayOverlay(node))) throw new TypeError("Overlay arrays cannot contain holes"); setArrayIndex(node, index, clonePlacement(supplied)); return true; } const key = String(property); if (supplied === undefined) return deleteProperty(node, key); const stored = clonePlacement(supplied); const current = objectValue(node, key); const wasDeleted = isObjectDeleted(node, key); if (!wasDeleted && !isContainer(stored) && current === stored) return true; setObjectWrite(node, key, stored); if (wasDeleted && Object.hasOwn(nodeBase(node), key)) { if (node.readded === undefined) node.readded = new Set(); node.readded.add(key); } deleteObjectDeletion(node, key); markDirty(node); return true;}clonePlacement dùng copyJson, vốn từ chối JSON không nghiêm ngặt trước khi draft đổi). Gán undefined cho thuộc tính là xoá nó; ghi lại đúng giá trị nguyên thuỷ đang có thì không làm gì.markDirty() đưa nút vào danh sách bẩn của ngữ cảnh và đánh dấu mọi tổ tiên là "cây con bẩn" (tracker.ts:761–766). Mảng có cấu trúc riêng: một cây các đoạn (piece) "lấy từ mảng gốc" và "chèn mới", tổ chức thành treap (cây nhị phân tìm kiếm giữ cân bằng bằng độ ưu tiên ngẫu nhiên), nên push, splice, reverse, sort chỉ sửa cây piece chứ không chép mảng (tracker.ts:1177–1243). Handle đọc từ draft đi theo phần tử qua các lần đổi chỉ số (delta/README.md:150–151).
prepare(): sinh phép toán từ các nút bẩn
prepare(): Prepared<T> { if (this.#settled) throw new Error("Change has already been settled"); const context = this.#context!; assertWritable(context); context.status.value = "prepared"; try { if (!context.replacement) context.ops = context.dirty.length === 0 ? [] : emitOperations(context); const prepared = materializePrepared<T>(context); this.#preparedStatus = context.status; this.#settled = true; return prepared; } catch (error) { context.status.value = "aborted"; clearContext(context); this.#settled = true; throw error; } finally { this.#context = undefined; }}prepared (chỉ đọc); khi prepare() trả về, ngữ cảnh đã được dọn và mọi lần dùng draft hay handle đọc từ nó đều ném lỗi (delta/README.md:18). Lỗi trong lúc sinh phép toán chuyển ngữ cảnh sang aborted và dọn sạch nó.emitOperations() đi qua các nút bẩn theo độ sâu, sinh phép toán cho object (ghi, xoá, xoá-rồi-thêm-lại để giữ thứ tự khoá) và cho mảng (một kế hoạch splice và hoán vị). Với giá trị từng thuộc tính, quyết định nằm ở một hàm nhỏ:
function emitChangedValue( operations: Op[], path: NonEmptyPath, before: JsonValue | undefined, after: JsonValue,): boolean { if (!isContainer(after) && before === after) return false; if (isContainer(before) && isContainer(after) && equalTrustedJson(before, after)) return false; if (typeof before === "string" && typeof after === "string") { // Slice/equality stays on V8's rope fast path; startsWith scans the entire // accumulated prefix on every append. if (after.length > before.length && after.slice(0, before.length) === before) { operations.push(["a", path, after.slice(before.length)]); return true; } const shared = overlap(before, after, 65_536); if (shared > 0) { operations.push(["t", path, before.length - shared]); if (after.length > shared) operations.push(["a", path, after.slice(shared)]); return true; } } operations.push(["s", path, after]); return true;}overlap(before, after, 65_536) tìm hậu tố dài nhất của chuỗi cũ là tiền tố của chuỗi mới (delta/index.ts:80–110). Đây là trường hợp một cửa sổ đuôi của output: cắt đầu rồi nối thêm.Hai giới hạn giữ lô phép toán nhỏ hơn giá trị. Quá MAX_DELTA_OPERATIONS (4.096) phép toán thì cả lô bị gộp thành một ["r", root] (tracker.ts:1666–1669). Và với thao tác mảng phổ biến (chèn, xoá mà các phần tử còn lại giữ thứ tự), kế hoạch được tính chỉ từ các piece, không duyệt phần tử:
/** * The plan when the retained base elements keep their order, the common case of inserts and removals: computed from the * pieces alone, so appending to a long array costs no walk over its elements. `undefined` when base elements moved. */function orderedArrayPlan(baseLength: number, pieces: readonly Piece[]): ArrayPlan | undefined { // Retained base ranges in piece order, `[first, last]` each; ordered means strictly ascending without overlap. const ranges: number[] = []; for (const piece of pieces) { if (piece.kind !== "base" || piece.length === 0) continue; if (piece.step !== 1 && piece.length > 1) return undefined; if (ranges.length > 0 && piece.start <= ranges[ranges.length - 1]!) return undefined; ranges.push(piece.start, piece.start + piece.length - 1); } // Unretained runs from the end backwards, as `start, length` pairs: after the last range, between ranges, before the first. const removeRuns: number[] = []; let end = baseLength; for (let index = ranges.length - 2; index >= -2; index -= 2) { const start = index >= 0 ? ranges[index + 1]! + 1 : 0; if (end > start) removeRuns.push(start, end - start); if (index >= 0) end = ranges[index]!; } return { removeRuns, permutation: undefined, insertRuns: insertRunsOf(pieces) };}Phép toán xong thì materializePrepared() dựng revision mới: không có phép toán thì value === base; thay đổi chỉ ở object thì chép thẳng các nút bẩn; còn lại thì áp lô phép toán bất biến lên base, chia sẻ mọi cây con không đổi (tracker.ts:325–349).
Ví dụ minh hoạ (lần theo code, không chạy): ví dụ của README
tracker = track({ output: "", count: 0 }) revision 0
change = tracker.beginChange()
change.state.output += "done\n" → nút gốc: writeKey "output"
change.state.count += 1 → nút gốc: writes {output, count}, gốc vào danh sách bẩn
prepared = change.prepare()
ops = [ ["a", ["output"], "done\n"], "done\n" bắt đầu bằng "" → append
["s", ["count"], 1] ] số nguyên thuỷ đổi → set
value = { output: "done\n", count: 1 } object mới; base không đổi
tracker.value === prepared.base authority chưa đổi
tracker.adopt(prepared) revision 1, tracker.value === prepared.value
adopt(): một phép đổi con trỏ không thể thất bại
beginChange(): Change<T> { const context = createContext(this.#selfRef, this.#owner, this.#revision, this.#value, false, this.#value); this.#register(context); return new ChangeImpl(context);}adopt(prepared: Prepared<T>): void { const context = PREPARED.get(prepared as object); if (context?.owner !== this.#owner) throw new Error("Prepared change belongs to a different tracker"); if (context.status.value === "consumed") throw new Error("Prepared change has already been used"); if (context.status.value === "aborted") throw new Error("Prepared change has been aborted"); if (context.status.value === "stale") throw new Error("Prepared change is stale"); if (context.status.value !== "prepared") throw new Error("Prepared change is not ready"); if (context.baseRevision !== this.#revision) { context.status.value = "stale"; clearContext(context); throw new Error("Prepared change is stale"); } if (this.#value !== prepared.base) { context.status.value = "stale"; throw new Error("Prepared change is stale"); } // Materialization happened during prepare. Adoption is an infallible pointer // swap so storage failure can discard the candidate without touching authority. this.#value = prepared.value; context.status.value = "consumed"; this.#revision += 1; this.#invalidate(context);}prepare(). adopt() chỉ kiểm tra rồi đổi con trỏ, nên tầng storage có thể vứt bỏ ứng viên khi ghi lỗi mà không chạm vào authority. #invalidate đánh dấu mọi draft và preparation cạnh tranh là stale.Nhiều change có thể mở hay đã prepare từ cùng một revision; adopt một cái làm mọi cái khác hết hạn: draft đang mở không dùng được nữa, preparation cạnh tranh bị adopt() từ chối (delta/README.md:124–136). Đó chính là cách Pi Durable dùng tracker trong một commit: prepare mọi document khi callback xong, ghi storage, chỉ adopt sau khi ghi thành công (durable/session/transaction.ts:709–712, transaction.ts:727–739).
Ai được đột biến cái gì
Tracker không đóng băng (freeze) và không sao chép phòng thủ. Bất biến là một hợp đồng sở hữu: đột biến trái phép không bị phát hiện, nó làm hỏng state trong im lặng (delta/README.md:6–7). Bảng quyền đột biến trong delta README (delta/README.md:28–40) tóm lại: gốc đưa cho track() hay replace() không được sửa nữa; change.state chỉ sửa được khi change còn mở; giá trị bên ngoài đặt vào draft vẫn của người gọi (draft giữ bản sao); mọi revision, mọi lô phép toán và mọi giá trị đọc từ state đều không được sửa.
Diff, apply và kiểm tra revision#
Tracker sinh phép toán từ các lần ghi qua draft. Có lúc không có draft: một derivation tính lại giá trị mới, hay một ứng dụng soi gương (mirror) một store bất biến bên ngoài bằng cell.replace(ctx, next). Khi đó Chord so hai revision bằng diffRevisions():
/** Compute a compact operation batch from two immutable JSON revisions. */export function diffRevisions(before: JsonValue, after: JsonValue): Op[] { const operations: Op[] = []; diffValue(before, after, [], operations); if (overflowedBatches.has(operations)) return [["r", after]]; if (operations.length === 0 || operations[0]?.[0] === "r") return operations; let deltaCost = 2; for (const operation of operations) deltaCost += operationCost(operation) + 1; if (deltaCost < 65_536) return operations; const snapshotCost = jsonCost(after) + 6; return deltaCost >= snapshotCost ? [["r", after]] : operations;}Diff dựa vào danh tính container: một revision mới của cùng state ({ ...state.value, changed }) chia sẻ các cây con không đổi, nên phép so === bỏ qua chúng ngay. Với mảng, nó lần lượt thử tiền tố và hậu tố chung, so theo vị trí, chuỗi con theo danh tính, các mỏ neo danh tính (dãy con tăng dài nhất), rồi LCS "ngữ nghĩa" có trần 65.536 ô, và nhận ra một hoán vị thuần để phát ra m (diff.ts:284–418). Chuỗi dùng cùng luật a/t/s như tracker (diff.ts:63–76). Đây là lý do guide khuyên giữ chia sẻ cấu trúc giữa các revision (delta/README.md:88): Cell.replace() chỉ diff mặc định khi giá trị mới dùng lại ít nhất một container của giá trị cũ ở cùng khoá (cell.ts:275–283).
Áp phép toán: bất biến và đột biến
/** Apply one decoded operation batch without mutating the previous immutable value. */export function applyImmutable<T>(target: T | undefined, ops: readonly Op[]): T { return applyImmutableBatches(target, [ops]);}/** * Apply decoded operation batches as one final-result-only replay. * * Containers copied for an earlier batch may be mutated while applying a later * batch, so this deliberately exposes no intermediate revisions. */export function applyImmutableBatches<T>(target: T | undefined, batches: Iterable<readonly Op[]>): T { let root = target as unknown as JsonValue; const owned = new WeakSet<object>(); for (const ops of batches) { for (const op of ops) { assertValidOp(op); if (op[0] === "r") { root = op[1]; continue; } if (op[0] === "p") { const appended = appendCopy(root, op, owned); if (appended !== undefined) { root = appended; continue; } } root = copyContainers(root, op[0] === "p" || op[0] === "m" ? op[1] : op[1].slice(0, -1), owned); root = applyOps(root, [op]); } } return root as unknown as T;}owned nhớ container nào đã được chép trong lần áp này, nên nhiều phép toán vào cùng một nhánh chỉ chép nhánh đó một lần. Kết quả chia sẻ container với cả đầu vào lẫn payload của phép toán.applyImmutableBatches() phát lại nhiều lô với một phạm vi copy-on-write chung, nên không lộ revision trung gian nào; Pi Durable dùng nó để đọc document từ base cộng chuỗi delta trong MemoryStorage (durable/storage/memory.ts:671–674). Bản đột biến apply() nhận một bản sao riêng và "nuốt" payload của lô vào nó: chép lô cho mỗi bản sao, đừng áp một lô trong bộ nhớ cho hai bản sao đột biến (delta/index.ts:335–345; delta/README.md:67–83). Backend SQLite và IndexedDB của Durable dùng apply() trên giá trị vừa đọc từ đĩa (với SQLite là kết quả của JSON.parse), nên bản sao là riêng của chúng (durable/storage/sqlite/storage.ts:649–655).
Khi tracker tự áp lô của chính nó, nó dùng applyImmutableTrusted(): bỏ kiểm tra hình dạng và an toàn đường dẫn vì lô do chính nó sinh, và tự viết splice bằng copyWithin (apply-immutable-trusted.ts:14–61); lô có p hay m thì vẫn đi đường applyImmutable() vì splice gốc nhanh hơn với lô lớn (tracker.ts:343–349).
Kiểm tra revision
JsonRevisionValidator kiểm một revision là JSON nghiêm ngặt (mảng dày, object thuần, không chu trình, số hữu hạn) và nhớ bằng WeakSet các container đã kiểm ở revision trước, nên một revision mới chỉ tốn công cho phần đã đổi (revision-validator.ts:7–42). Ở v1.4.0 không file nguồn nào nhập lớp này, và delta/index.ts không xuất nó; benchmark duyệt draft chỉ băm nội dung file để ghi lại phiên bản đo. Kiểm tra thật sự ở các ranh giới khác: placement qua draft được copyJson kiểm, gốc trao cho track() thì được tin (delta/README.md:96–113), và state đi qua link được kiểm theo schema của contract (mục link).
replicatedState: state quan sát được, không cần host#
Tracker cho revision và phép toán; một giao diện còn cần quan sát: đọc giá trị hiện tại, được báo khi nó đổi, theo đúng thứ tự, và một người nghe chậm không được chặn người khác. replicatedState là lớp đó, độc lập với host; README ghi Durable dùng nó cho state của document, view và task graph (README.md:154–156).
export interface ReplicatedStateDelivery { readonly kind: "hydrate" | "update"; readonly sequence: number;}export interface ReplicatedState<T> { /** * Contract-immutable value, or undefined until hydration. It is not frozen and may share containers with an * in-process provider; consumers must not mutate it. Later updates do not mutate previously returned values. */ readonly value: T | undefined; /** * Each subscription serializes callbacks, awaiting hydration before updates. Values are immutable and may * share unchanged revision data. At most 100 deliveries wait behind the running callback; overflow keeps * only the newest pending value/context/delivery, so update sequences may skip. Failures are reported in * isolation and delivery continues. Unsubscribe discards pending work without aborting or joining a callback. */ subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => Promise<void>): () => void; subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void;}export interface MutableReplicatedState<T extends object> extends ReplicatedState<T> { readonly value: T; /** * Atomically publish one synchronous overlay mutation. Draft handles are unusable after the callback returns. * Values placed through the draft are cloned by value; assigning `undefined` to an object property deletes it. */ change(context: Context, mutate: (draft: Draft<T>) => void): void; /** * Atomically take immutable ownership of an alias-free strict-JSON replacement. * The caller must not mutate the transferred root after this call. */ replace(context: Context, value: T): void;}delivery (hydrate hay update, kèm số thứ tự). Giá trị không bị đóng băng và có thể chia sẻ container với nơi cung cấp: không được sửa.Một change() thành công công bố đúng một revision nguyên tử; callback ném lỗi thì giá trị và số thứ tự giữ nguyên (README.md:169–173). Hiện thực là tracker cộng một publisher:
change(context: Context, mutate: Parameters<MutableReplicatedState<T>["change"]>[1]): void { if (this.#changing) throw new Error("Replicated state cannot be changed reentrantly from a change callback"); this.#changing = true; let prepared: Prepared<T>; try { const change = this.#tracker.beginChange(); try { const outcome = (mutate as (draft: typeof change.state) => unknown)(change.state); if (isPromiseLike(outcome)) { void Promise.resolve(outcome).catch(() => undefined); throw new TypeError("Replicated state change callbacks must be synchronous"); } prepared = change.prepare(); } catch (error) { change.abort(); throw error; } } finally { this.#changing = false; } this.#tracker.adopt(prepared); if (prepared.ops.length > 0) this.#publisher.publish(prepared.value, context);}replace(context: Context, value: T): void { if (this.#changing) throw new Error("Replicated state cannot be replaced from a change callback"); const prepared = this.#tracker.prepareReplace(value); this.#tracker.adopt(prepared); if (prepared.ops.length > 0) this.#publisher.publish(prepared.value, context);}Giao từng subscriber một, có trần
ReplicatedStatePublisher.subscribe() giao ngay giá trị hiện tại làm lần hydrate (lần nạp giá trị đầu tiên), với BACKGROUND_CONTEXT vì hydrate không có người gây ra (state.ts:201–212). publish() chống tái nhập (reentrancy): một subscriber đổi state ngay trong callback thì revision mới xếp hàng sau revision đang giao, không chen lên trước (state.ts:214–238). Mỗi subscriber có hàng đợi riêng:
push(frame: StateDelivery<T>): void { if (this.#closed) return; if (this.#pending.length === 100) { // A cold replica can queue updates reentrantly before this subscriber's first hydration starts. const hydration = this.#started ? undefined : this.#pending[0]; this.#pending.length = 0; if (hydration !== undefined) this.#pending.push(hydration); } this.#pending.push(frame);}drain(): void { if (this.#running || this.#closed) return; this.#running = true; for (let frame = this.#pending.shift(); frame !== undefined; frame = this.#pending.shift()) { this.#started = true; try { const result: unknown = this.#listener(frame.value, frame.context, frame.delivery); if (isPromiseLike(result)) { void Promise.resolve(result).then( () => this.#resume(), (error: unknown) => { this.#report(error); this.#resume(); }, ); return; } } catch (error) { this.#report(error); } } this.#running = false;}Gắn vào một nguồn revision bên ngoài
Dạng thứ hai, replicatedState(source, { onError }), không tự sở hữu tracker. Nó gắn vào một nguồn có thẩm quyền (authoritative) đã tự commit revision, như Session của Pi Durable:
/** One immutable authoritative revision committed after an attachment snapshot. */export interface ReplicatedStateSourceFrame<T> { /** Monotonic source cursor. The first frame after a snapshot must be `snapshot.cursor + 1`. */ readonly cursor: number; /** The exact immutable value produced by this commit. */ readonly value: T; /** The exact immutable operation batch that produced `value` from the preceding source revision. */ readonly ops: readonly Op[]; readonly context: Context;}export interface ReplicatedStateSourceAttachment<T> { /** Fixed immutable snapshot captured at the atomic attachment boundary. */ readonly snapshot: { readonly value: T; readonly cursor: number }; /** * Install the sole listener and synchronously drain every buffered frame in source commit order. * This method is single-use. After it begins, every new committed frame must also be delivered in order * until disposal, including commits made reentrantly while a prior frame is being delivered. */ activate(listener: (frame: ReplicatedStateSourceFrame<T>) => void): void; /** Stop delivery and release source resources. Disposal must be idempotent. */ dispose(): void;}/** * An authoritative immutable revision source. * * `attach()` must synchronously and atomically capture one snapshot and register the returned attachment to buffer * every later committed frame. The snapshot must include every commit before that boundary; buffered frames must * include every commit after it, with no overlap or gap. Snapshot values, frame values, and operation batches are * immutable and remain valid after delivery. Chord only publishes these references; it never applies or re-diffs them. */export interface ReplicatedStateSource<T> { attach(): ReplicatedStateSourceAttachment<T>;}attach() phải chụp ảnh và đăng ký nhận frame trong cùng một bước đồng bộ, để mọi commit trước ranh giới nằm trong ảnh chụp và mọi commit sau nằm trong frame, không trùng không hở. Chord chỉ công bố các tham chiếu này, không bao giờ áp hay diff lại chúng.Phía Chord kiểm tra hợp đồng đó: cursor (số thứ tự tăng dần của frame) của frame phải là cursor trước cộng một, lệch là lỗi, state tự gỡ nguồn và báo qua onError (state.ts:326–339). Pi Durable hiện thực nguồn này bằng CommittedStateSource: attach() lấy giá trị và cursor hiện tại, advance(value, ops, context) tăng cursor và phát một frame cho mọi attachment sau mỗi commit chạm tới document hay view (durable/session/observation.ts:25–62; chi tiết ở chương Durable storage).
Cell, derivation và hàng đợi publication#
Bên trong host runtime, state của object nằm trong cell, khai báo bằng self.state(name, schema, initial). Cell cũng dùng tracker, nhưng có mô hình đăng ký riêng (README.md:201–202): đồng bộ, đi qua một hàng đợi chung của tiến trình, và giao sự kiện delta chứ không chỉ giá trị. Giao diện mọi nguồn state (cell, derivation, member state của một reference) cùng tuân theo:
export type StateEvent<T> = | { readonly type: "snap"; readonly value: T } | { readonly type: "ops"; readonly ops: readonly Op[]; readonly value: T } | { readonly type: "reset"; readonly value: T };/** A readable state source: a cell, a derivation, or a reference's state member. Values are immutable by contract. */export interface Readable<T> { readonly value: T; /** Called with the current value immediately, then with every new value. */ subscribe(listener: (value: T) => void): () => void; /** Delta stream: a snapshot immediately, then ops, or a reset after a migration or rebind. */ events(listener: (event: StateEvent<T>) => void): () => void;}Tracker chỉ theo dõi container, mà một cell có thể chứa số hay chuỗi. Cell vì vậy bọc giá trị trong { v: value } và gỡ lớp bọc khỏi phép toán trước khi công bố:
/** The tracker tracks containers, so a cell tracks `{ v: value }` and strips the box from published ops. */interface Box<T> { v: T;}function unbox(ops: readonly Op[], value: unknown): Op[] { const result: Op[] = []; for (const op of ops) { if (op[0] === "r" || op[1].length === 0) return [["r", value as JsonValue]]; const path = op[1].slice(1); switch (op[0]) { case "p": result.push(["p", path, op[2], op[3], op[4]]); break; case "m": result.push(["m", path, op[2]]); break; default: // s/d/a/t on the value itself replace it. if (path.length === 0) return [["r", value as JsonValue]]; if (op[0] === "d") result.push(["d", path as [string | number, ...(string | number)[]]]); else result.push([op[0], path as [string | number, ...(string | number)[]], op[2]] as Op); } } return result;}v (gán số mới cho cell kiểu số) trở thành ["r", value] của cell./** * Mutates a draft (only mutated paths are published), or returns a new value (`n => n + 1`). A returned value is * always validated against the schema. The draft is unusable after `fn` returns. */change(ctx: Ctx, fn: (draft: Draft<T>) => T): void;change(ctx: Ctx, fn: (draft: Draft<T>) => void): void;change(ctx: Ctx, fn: (draft: Draft<T>) => unknown): void { this.#guard(ctx); if (!this.#initialized) throw notReady(`state ${this.key} (its init has not run)`); const change = this.#tracker.beginChange(); let prepared: ReturnType<typeof change.prepare>; try { const draft = change.state as Draft<Box<T>>; const result: unknown = fn(draft.v as Draft<T>); if (result instanceof Promise) throw new TypeError("cell.change callbacks must be synchronous"); if (result !== undefined) { if (result !== draft.v) assertValid("state", `state ${this.key}`, this.schema, result); draft.v = result as Draft<T>; } prepared = change.prepare(); } catch (error) { change.abort(); throw error; } this.#publish(prepared, "ops");}guard(ctx) là cổng ghi của object: không ghi trong factory, không ghi với ctx đã huỷ hay ctx của host khác, và chịu kiểm tra code version khi reload (object.ts:1546–1553). Callback trả về một giá trị (n => n + 1) thì giá trị đó được kiểm theo schema rồi gán vào v.#publish() là chỗ cell khác replicatedState: trước khi adopt, nó tính lại mọi derivation phụ thuộc (bắc cầu) trên giá trị ứng viên, và ở chế độ dev kiểm chúng theo schema (cell.ts:110–115); một derivation ném lỗi, hay một giá trị sai schema ở chế độ dev, huỷ preparation và cell giữ nguyên. Chỉ khi mọi thứ hợp lệ, nó adopt cell, gửi sự kiện, rồi cài giá trị mới cho các derivation, tất cả bên trong holdPublications:
/** Adopts this revision and every derived one, then queues all notifications (cell first, then derivations). */#publish( prepared: ReturnType<Tracker<Box<T>>["prepareReplace"]>, type: "ops" | "reset", validate = true, ops?: readonly Op[],): void { if (prepared.ops.length === 0 && type === "ops") { this.#tracker.adopt(prepared); return; } const value = prepared.value.v; let candidates: Map<Derived<unknown>, unknown>; try { if (validate) this.validate(value); candidates = computeDependents(this as Source<unknown>, value, validate); } catch (error) { prepared.abort(); throw error; } holdPublications(() => { this.#tracker.adopt(prepared); const published = ops !== undefined && ops.length > 0 ? ops : unbox(prepared.ops, value); this.fanout.emit(type === "reset" ? { type, value } : { type, ops: published, value }); for (const [derived, next] of candidates) derived.install(next); });}Lý do của holdPublications và enqueue được viết ngay đầu publish.ts, theo đúng lối "bài toán, ví dụ, lời giải":
// One FIFO queue for every observer notification (state events, shape pushes, registry tables, listener// invocations).//// Problem: notifying observers synchronously from inside a write lets a re-entrant write overtake the write being// published. Example: subscriber A reacts to n = 1 by writing n = 2; subscriber B then sees 2 before 1.// Solution: a publication first adopts every revision it makes (cells and derivations), then enqueues its// notifications. Notifications run in enqueue order, so every observer sees revisions in adoption order.// `holdPublications` defers delivery until a multi-step change (a reload commit) is complete.const queue: (() => void)[] = [];let held = 0;let draining = false;/** Runs `task` after everything enqueued before it. Tasks must not throw (report instead). */export function enqueue(task: () => void): void { queue.push(task); drain();}/** Delivers nothing until `fn` returns, then everything it enqueued, in order. */export function holdPublications<T>(fn: () => T): T { held++; try { return fn(); } finally { held--; drain(); }}n = 1 bằng cách ghi n = 2, và subscriber B thấy 2 trước 1. Với nó, mọi người quan sát thấy revision theo thứ tự adopt.Derivation (self.derive(sources, fn)) là một phép chiếu đồng bộ. Nguồn là cell hay derivation cùng object thì được tính lại ngay trong lần ghi (như trên); nguồn là thứ khác (member state của một reference) thì được đăng ký và tính lại khi nguồn công bố. Mỗi lần cài giá trị mới, derivation phát lô phép toán bằng diffRevisions() giữa giá trị cũ và mới (cell.ts:388–396). Derivation không có tên và được tạo mới ở mỗi lần reload; cell có tên và giữ giá trị qua reload (mục hot reload).
Contract, schema và luật tương thích#
Bài toán: plugin được build riêng và chở đi dưới dạng bundle JavaScript, không còn TypeScript. Một bên cung cấp (provider) và một bên dùng (consumer) có thể được build từ hai phiên bản khác nhau, ở hai tiến trình khác nhau. Runtime phải tự kiểm hai bên có khớp không và kiểm dữ liệu đi qua link. Vì vậy contract là giá trị lúc chạy; kiểu TypeScript được suy ra từ nó (guide.md:76). Ví dụ nhanh của README:
// contract.ts: shared by provider and consumersexport const Counter = remote("demo.counter", { version: 1, input: Type.Object({ name: Type.String() }), host: hostNamed("server"), // where its objects live; callers never name a host variants: { default: { state: { count: Type.Number() }, methods: { increment: method(Type.Object({ by: Type.Number() })) }, }, },});const host = createHost({ name: "server", self: { kind: "server" } });host.serve(Counter, ({ name }, _principal, self) => { const count = self.state("count", Type.Number(), 0); return { variant: "default", count, increment: async ({ by }, ctx) => { count.change(ctx, (n) => n + by); }, };});await host.root(async (ctx) => { const clicks = await ctx.get(Counter, { name: "clicks" }); // ready, with its state; released when this root ends clicks.count.subscribe((n) => console.log("count", n)); await clicks.increment({ by: 2 }, ctx); // logs 0, then 2});{ name } là địa chỉ của object, không phải tham số của method. host: hostNamed("server") nói object sống ở đâu, nên người gọi không bao giờ nêu tên host.Có hai loại contract. remote() cho loại object gọi được từ host khác: mọi thứ đi qua dây đều có schema (input, member state theo từng variant, tham số và kết quả của method), và bắt buộc có placement. local() cho object chỉ dùng trong host: member có thể là bất cứ gì (hàm, callback, object gốc của Node), nhưng phải được liệt kê (method, opaque("name@version"), hoặc một schema state) để một reference còn đang loading biết ref.x là method, state hay giá trị (spec.md:56).
export function remote<const D extends RemoteDef>( name: string, def: D & { readonly host: PlacementFor<Static<D["input"]>> },): RemoteContract<D> { checkHeader(name, def.version); const placement = def.host as Placement | undefined; if (placement === undefined || !Value.Check(PlacementSchema, placement)) throw new TypeError(`remote contract ${name} needs \`host\`: hostNamed(…) or hostFamily(…)`); // A JSON copy: the description travels, and a placement object may carry nothing else. const host: Placement = "name" in placement ? { name: placement.name } : { prefix: placement.prefix, key: placement.key }; const variants: Record<string, Record<string, MemberDescription>> = {}; if (Object.keys(def.variants).length === 0) throw new TypeError(`remote contract ${name} has no variants`); for (const [kind, variant] of Object.entries(def.variants)) { const members: Record<string, MemberDescription> = {}; for (const [key, schema] of Object.entries(variant.state ?? {})) members[key] = describeMember(key, schema, "remote"); for (const [key, spec] of Object.entries(variant.methods ?? {})) { if (key in members) throw new TypeError(`member ${key} is both state and method in ${name}.${kind}`); members[key] = describeMember(key, spec, "remote"); if (members[key].kind !== "method") throw new TypeError(`methods.${key} is not a method()`); } variants[kind] = members; } const description: Description = { name, scope: "remote", version: def.version, input: def.input, host, variants }; return finish<RemoteContract<D>>({ scope: "remote", name, version: def.version, input: def.input, def, description, });}finish() gắn fingerprint = hash64(canonicalJson(description)) (contract.ts:210–212): một hash 64 bit không mật mã, hai làn FNV-1a, đồng bộ và chạy được trong trình duyệt; contract là thứ được tin nên va chạm không phải bề mặt tấn công (schema.ts:21–31).Mô tả (Description) là một object JSON thuần, có schema riêng (contract.ts:147–156), vì nó đi qua dây làm "token" của consumer trong frame hold. Ở phía nhận, contractFromDescription() dựng lại một contract chỉ dùng để bind và kiểm, không mang kiểu TypeScript (contract.ts:268–286).
Input là địa chỉ
{ session: "S1", conversation: 1 } luôn chỉ cùng một object. Input được chuẩn hoá (điền mặc định, bỏ trường lạ), kiểm, rồi tuần tự hoá chính tắc; trường bằng giá trị mặc định bị bỏ khỏi danh tính, nên thêm một trường tuỳ chọn có mặc định không đổi địa chỉ cũ (guide.md:133; schema.ts:45–55). Đổi một giá trị mặc định thì phải đổi tên loại object.
Placement của loại remote là dữ liệu: hostNamed("server"), hoặc hostFamily("session:", "session") nghĩa là host session: cộng trường session của input (contract.ts:63–92). Vì placement nằm trong mô tả, nó nằm trong fingerprint: chuyển một loại object sang host khác là thay đổi phá vỡ (guide.md:108–113).
Luật tương thích
/** How a consumer token binds to a provider: directly or through an `accepts` adapter. Throws contract_mismatch. */export function bind(provider: AnyContract, accepts: Accepts | undefined, consumer: AnyContract): Adapter | undefined { if (provider.fingerprint === consumer.fingerprint) return undefined; // A moved type is another type for its consumers: no adapter reaches across hosts. if (!samePlacement(provider.description, consumer.description)) { throw new ChordError( "contract_mismatch", `${provider.name} lives on ${placementOf(provider.description)}, the consumer expects ${placementOf(consumer.description)}`, ); } if (provider.version === consumer.version) { const reason = coverage(provider.description, consumer.description); if (reason === undefined) return undefined; throw new ChordError( "contract_mismatch", `${provider.name}@${provider.version} does not cover the consumer's description (${reason}); forgotten version bump?`, ); } const adapter = accepts?.[consumer.version]; if (adapter === undefined) { throw new ChordError( "contract_mismatch", `${provider.name}: provider version ${provider.version}, consumer version ${consumer.version}, no accepts adapter`, ); } return adapter;}"Phủ" (cover) được định nghĩa từng member trong coverage(): mỗi member của consumer phải có ở provider với cùng loại; method thì hai bên cùng nhận hoặc cùng không nhận tham số, provider nhận được mọi tham số consumer có thể gửi, và kết quả của provider vừa với kết quả consumer chờ; state thì giá trị của provider vừa schema của consumer; opaque thì cùng name@version (contract.ts:288–314). Câu hỏi "vừa" trả lời bằng fits(sub, sup), một phép kiểu con cấu trúc bảo thủ trên JSON Schema: chỉ trả về đúng khi mọi giá trị hợp lệ theo sub chắc chắn hợp lệ theo sup, và một từ khoá nó không mô hình (not, allOf, $ref...) buộc hai schema phải giống hệt (schema.ts:128–160).
Hệ quả thực hành, theo bảng của guide (guide.md:147–152): thêm method, thêm tham số tuỳ chọn, thêm trường kết quả hay trường state thì giữ version; bỏ hay đổi tên member, biến tham số thành bắt buộc, đổi kiểu thì tăng version và cung cấp accepts cho consumer cũ; đổi nghĩa mà giữ hình dạng (ví dụ query giờ là regex) cũng phải tăng version, và không gì phát hiện được việc đó thay bạn. Adapter là hàm biến implementation hiện tại thành view mà consumer bản cũ mong đợi (guide.md:154–161).
Kiểu của một reference được suy ra từ contract. Với loại remote nhiều variant, nó là một union phân biệt bằng variant: member của variant khác chỉ với tới được sau khi thu hẹp kiểu (contract.ts:431–440).
Host, object, reference và activation#
Host là runtime sở hữu các object của một tiến trình (hay một tab). createHost({ name, self, principalKey }) bắt buộc self: principal mà code của chính host hành động thay mặt. Chord không tự đặt ra principal nào; ứng dụng cung cấp mọi principal (host.ts:109–124). Một facet của plugin gọi host.serve(type, factory, options); factory chạy đồng bộ, khai báo những gì object cần qua self, và trả về implementation (guide.md:163–175).
export interface Self<P = unknown> { /** * A state cell named `name`: its schema (required: there is no TypeScript at runtime) and its initial value * (validated), or `{ init }` to read the first value from its source before the object goes live. The name identifies * the cell across reloads: a reload keeps the cell and its value (`initial` is ignored, `init` does not run), migrates * it when its version rises, and refuses a schema change without a version bump. A cell a reload adds runs its `init` * while the reload prepares. */ state<S extends TSchema>( name: string, schema: S, initial: Static<S> | StateInit<Static<S>, P>, options?: StateOptions<Static<S>>, ): Cell<Static<S>>; /** An object-owned reference, identified by its address. */ get<C extends AnyContract>(type: C, input: InputOf<C>, options?: GetOptions<P>): Ref<C>; /** * A resource named `name`: opened before the object goes live, closed after it is disposed. A reload keeps it unless * its `version`, `deps` or `exclusive` changed. */ resource<T>(name: string, declaration: ResourceDeclaration<T>): Resource<T>; /** * Reacts to `source` once the object is live: a readable (cell, derivation, reference state member) delivers its * current value, then every new value; an event source (or a resource whose value is one) delivers its events. * With `select`, subscribes to `select(value)` instead (the opened value for a resource). Each event runs `handler` * in a fresh root. The name keeps the subscription across reloads while its source and `select` are unchanged; a * listener on state whose source the reload replaced (a derivation is new at every reload) subscribes again and * gets the current value again, so a handler of state must reconcile, not count. */ on<S>(name: string, source: S, handler: (event: EventOf<S>, ctx: Ctx<P>) => void | Promise<void>): void; on<S, E>( name: string, source: S, select: (value: ValueOf<S>) => Subscribable<E>, handler: (event: E, ctx: Ctx<P>) => void | Promise<void>, ): void; /** Long-running work of the live object (a code holder: its calls start fresh roots), restarted at every reload. */ task(fn: (ctx: Ctx<P>) => Promise<void>): void; /** A synchronous projection of readables, recomputed when they change; a new one at every reload. */ derive<const S extends Sources, T>(sources: S, fn: (...values: SourceValues<S>) => T): Readable<T>; /** Objects of lifetime `"host"` only. Keys are the registry's own keys. */ register<V>(registry: Registry<V>, entries: Readonly<Record<string, V>>): void; /** * A member backed by one of this object's resources, for local contracts: references read the resource's value once * the object is live (`undefined` before). Typically an `opaque` member: `{ harness: self.expose(durable) }`. */ expose<T>(resource: Resource<T>): Exposed<T>;}Bảng khai báo của guide nói mỗi thứ sống sót qua reload thế nào (guide.md:203–212). Một quy tắc đi kèm hay bị quên: state cần sống qua reload phải nằm trong cell, không nằm trong biến closure, vì factory chạy lại ở mỗi lần reload (guide.md:222–241).
Lấy một reference: acquire
Mọi đường (self.get, ctx.get, host.get, host.hold, một frame hold từ link) đều đi qua HostImpl.acquire():
acquire( type: AnyContract, input: unknown, options: GetOptions | undefined, owner: RefOwner, from?: ObjectImpl, candidate = false,): RefCore { const elsewhere = this.#elsewhere(type, input); if (elsewhere !== undefined) return this.#acquireRemote(type, elsewhere.at, elsewhere.input, options, owner); const service = this.#bindingService(type.name, candidate); if (service === undefined) throw unknownType(type.name, this.name); bind(service.contract, service.options.accepts, type); const { address, normalized, principal } = this.#locate(service, input, options?.principal ?? this.principal); let object = this.objects.get(address); if (from !== undefined && object !== undefined && (object === from || reaches(object, from))) { throw invalid("cycle", `${from.address} -> ${address} would create a ref cycle`); } if (object === undefined) { if (this.gating?.has(type.name)) { throw new ChordError("upgrade_conflict", `${type.name} is being upgraded; retry`, { reason: "gate" }); } const dying = this.#dying.get(address); if (dying?.status === "disposed") this.#dying.delete(address); object = new ObjectImpl(this, { address, typeName: type.name, input: normalized, principal, hostLifetime: hostLifetime(service), }); this.objects.set(address, object); // A new object for an address still being disposed waits for that cleanup (exclusive resources). object.start(dying?.status === "disposing" ? dying.dispose(false) : undefined); } const ref = new RefImpl(object, type, owner); object.addRef(ref); return ref;}invalid/placement, bất kể request nói nó đi đâu. Một tham chiếu tạo chu trình trong cùng host bị từ chối ngay ở đây.Địa chỉ của object là tên loại \0 danh tính chính tắc của input \0 phần principal, trong đó principal của chính host (so bằng principalKey) là host (host.ts:515–533). Nên có đúng một object cho mỗi (type, input, principal key): hai tab của cùng chủ sở hữu dùng chung một object, một khách (guest) có object riêng, và code bên trong host luôn hành động như self nên các object nội bộ được chia sẻ (guide.md:356–362).
Activation: factory đồng bộ, việc chậm làm sau
Factory không được await; mở database hay chờ một object khác là việc của activation, chạy sau khi factory đã khai báo xong:
async #activate(after: Promise<void> | undefined): Promise<void> { await after?.catch(noop); const signal = this.activationAbort.signal; const abandoned = (): ChordError => new ChordError("aborted", `${this.address} abandoned`); if (signal.aborted) throw abandoned(); const service = this.host.service(this.typeName); if (service === undefined) throw new ChordError("unavailable", `${this.typeName} is not served`, { reason: "unknown_type" }); this.service = service; const run = this.run(service, undefined); try { await this.open(run.created.resources, run.created.refs, signal); if (signal.aborted) throw abandoned(); await this.waitHydrated(run); if (signal.aborted) throw abandoned(); await this.initCells(run, signal); if (signal.aborted) throw abandoned(); this.host.checkRegistrations(this, run.decls); this.decls = run.decls; for (const derived of run.created.derives) derived.attach(); this.refreshSchemas(); // Served only by a running reload's pending code version: new code must not run (listeners, tasks, // registrations) before that reload commits. this.deferredRuntime = !this.implementations.has(this.host.currentCodeVersion()); if (!this.deferredRuntime) { if (run.decls.registrations.size > 0) this.host.registriesChanged(this); this.startRuntime(); } // A listener starting up may have released the last reference synchronously: no resurrection. if (signal.aborted) throw abandoned(); this.status = "live";superseded nếu code đổi trong lúc activation. Mọi lỗi ở bất kỳ bước nào gỡ lại toàn bộ: dừng listener và task, đóng resource đã mở theo thứ tự ngược, nhả reference (object.ts:1143–1157).Các bước tương ứng với sơ đồ năm bước của guide (guide.md:249–256): chạy factory (inFactory tăng, nên dùng thứ vừa khai báo trong factory là not_ready), mở resource theo phụ thuộc và chờ reference sẵn sàng trong readyTimeout (mặc định 60 s), chờ mọi state được expose có giá trị, chạy init của các cell khai báo bằng { init }, kiểm registry, gắn derivation, rồi khởi động listener và task. Nhiều get cùng một địa chỉ dùng chung một activation; nếu mọi người nhả trước khi xong, kết quả bị huỷ (guide.md:276–281).
Lifetime, principal và variant
Object sống khi còn được tham chiếu. lifetime: "referenced" (mặc định) tạo object ở lần get đầu và huỷ sau khi tham chiếu cuối được nhả cộng linger; lifetime: "host" chỉ dành cho loại local có input rỗng, tạo một object duy nhất khi facet nạp và giữ tới khi host đóng, và chỉ object loại này được ghi vào registry (spec.md:135–152). Khi hết tham chiếu và hết lời gọi đang chạy, #maybeIdle() hẹn huỷ (object.ts:1646–1666). Huỷ dừng task, gỡ listener, chờ mọi lời gọi xong, đóng resource theo thứ tự mở ngược, rồi nhả các reference của chính object, có thể kéo theo huỷ object khác (object.ts:1681–1721).
Factory của loại remote chọn một variant theo principal: khách xem được trả về { variant: "viewer", view }, người tham gia nhận thêm send và stop (guide.md:366–381). Variant không phải toàn bộ phân quyền: method vẫn tự kiểm tham số, cờ thay đổi trong lúc giữ object là state, và thu hồi là host.invalidatePrincipal(match), ép huỷ các object khớp ở đây và qua link (guide.md:389–392).
Root, Ctx và một lời gọi method#
Một root là một cây lời gọi ngắn, mở ở điểm vào tin cậy: link nhận một request không mang root, host.root(fn) cho timer và script, mỗi sự kiện giao cho một listener, mỗi lời gọi Chord từ một task (guide.md:495–501). Root chạy trên code version hiện hành lúc nó bắt đầu, xuyên qua các host, cho tới khi kết thúc. Đó là cơ chế để hot reload không cắt ngang công việc đang chạy.
async root<T>(fn: (ctx: Ctx) => T | Promise<T>, options: RootOptions = {}): Promise<T> { // The incarnation keeps root ids unique across restarts of a host with the same name. const rootId = `${this.name}#${this.#incarnation}:${++this.#rootSeq}`; this.#local.add(rootId); return this.joinRoot(rootId, options.codeVersion ?? this.current.id, fn, options);}async joinRoot<T>( rootId: string, codeVersionId: number, fn: (ctx: Ctx) => T | Promise<T>, options: { readonly principal?: unknown; readonly signal?: AbortSignal; readonly from?: Connection } = {},): Promise<T> { const codeVersion = this.codeVersions.get(codeVersionId); if (codeVersion === undefined) { this.#local.delete(rootId); throw new ChordError("superseded", `code version ${codeVersionId} is not retained by ${this.name}`); } codeVersion.roots.set(rootId, (codeVersion.roots.get(rootId) ?? 0) + 1); const ctx = new CtxImpl(this, { // Forced retirement aborts what still runs on a code version before closing what only it used. signals: options.signal === undefined ? [codeVersion.abort.signal] : [options.signal, codeVersion.abort.signal], rootId, codeVersion: codeVersion.id, principal: options.principal ?? this.principal, }); try { return await fn(ctx); } finally { ctx.close(); const count = (codeVersion.roots.get(rootId) ?? 1) - 1; if (count > 0) codeVersion.roots.set(rootId, count); else { codeVersion.roots.delete(rootId); if (!this.#foreign.has(rootId)) this.endRoot(rootId); this.maybeRetire(); this.#retirementChanged(); } }}rootId mang một "incarnation" ngẫu nhiên để duy nhất qua các lần khởi động lại của host cùng tên. Code version không còn được giữ thì root bị từ chối superseded (thử lại được). Khi lời gọi cuối của root ở host này xong, host thử retire code version cũ.CtxImpl gộp tín hiệu huỷ của người gọi với tín hiệu của code version, nên một lần retire cưỡng bức huỷ được mọi root còn chạy trên code cũ (object.ts:435–454). Nó cố tình không dùng AbortSignal.any: Node giữ các tín hiệu phụ thuộc còn listener sống theo tín hiệu nguồn sống lâu, nên mỗi ctx sẽ giữ closure (và code) của nó lâu như object (object.ts:485–510; CHANGELOG.md:67). ctx.get() lấy một reference thuộc về lời gọi; close() nhả nó khi lời gọi xong (object.ts:456–482).
Reference là một proxy gắn muộn
Người gọi không bao giờ cầm implementation. RefImpl lộ ra qua một Proxy: các trường cơ sở (status, ready, variant, release...) đọc thẳng, mọi tên khác đi qua member(name), tra theo contract của consumer và implementation hiện hành (object.ts:789–799). Method trả về một hàm ổn định luôn gọi tới code hiện hành; member state trả về một MemberReadable ổn định, tự đăng ký lại khi reload hay đổi variant đổi nguồn của nó và báo một reset (object.ts:613–618). Gọi không kèm ctx, hay từ một task (code holder), thì mở một root mới (object.ts:944–973).
async call(ref: RefImpl, name: string, args: unknown, ctx: CtxImpl): Promise<unknown> { ctx.check(this.host); if (ref.status === "released" || ref.status === "gone") throw ref.terminalError(); if (ref.status === "loading") await raceSignal(ref.ready, ctx.signal); if (ref.status !== "live" || this.status !== "live") { throw ref.status === "live" ? new ChordError("gone", `${this.address} is ${this.status}`) : ref.terminalError(); } this.admit(ctx); const installed = this.implementations.get(ctx.codeVersion); if (installed === undefined) throw new ChordError( "superseded", `${this.address} has no implementation for code version ${ctx.codeVersion}`, ); const view = ref.viewOf(installed); const variant = variantOf(view); const wanted = ref.describe(ref.contract.scope === "remote" ? variant : LOCAL_VARIANT, name); const fn = view[name]; if (typeof fn !== "function" || (ref.contract.scope === "remote" && wanted?.kind !== "method")) { throw new ChordError( "not_in_variant", `${this.address}: ${name} is not a method of variant ${variant ?? "(local)"}`, ); } let input: unknown; if (takesArgs) { // The additive rule assumes a consumer sends only what its own schema knows, and a provider sees only what its // schema knows: strip with both before validating. const own = ref.contract.scope === "remote" && wanted?.kind === "method" ? wanted.args : undefined; const sent = own === undefined ? args : Value.Clean(own, Value.Clone(args)); input = validated?.args ? Value.Clean(validated.args, Value.Clone(sent)) : sent; if (validated?.args) assertValid("args", `${this.address}.${name} args`, validated.args, input); } this.lease(ctx); const child = ctx.child(this); let result: unknown; try { result = await this.#invoke(() => takesArgs ? (fn as (args: unknown, ctx: Ctx) => unknown)(input, child) : (fn as (ctx: Ctx) => unknown)(child), ); } finally { child.close(); } if (!returns) return undefined; if (validated?.result) assertValid("result", `${this.address}.${name} result`, validated.result, result); return result;admit(ctx) là cổng của mọi lời gọi và mọi lần ghi cell: host đã hỏng thì unavailable/host_failed; code version không còn thì superseded; object đã được nâng cấp ở code version mới hơn thì upgrade_conflict; object đang ở sau cổng (gate) của một reload mà root này chưa từng chạm vào nó thì cũng upgrade_conflict (object.ts:1521–1537). lease(ctx) ghi nhận root đã chạm vào object, để một gate sau đó cho phép nó đi tiếp.
Lỗi
/** Error codes callers see (guide table). A refused or aborted reload keeps the code of its cause. */export const CHORD_ERROR_CODES = [ "aborted", "contract_mismatch", "forbidden", "gone", "invalid", "not_in_variant", "not_ready", "released", "superseded", "unavailable", "upgrade_conflict", "upgrade_required",] as const;export type ChordErrorCode = (typeof CHORD_ERROR_CODES)[number];const RETRYABLE: ReadonlySet<ChordErrorCode> = new Set(["superseded", "upgrade_conflict"]);export class ChordError extends Error { readonly code: ChordErrorCode; readonly reason: ChordErrorReason | undefined; readonly retryable: boolean; readonly details: unknown; constructor( code: ChordErrorCode, message: string, options?: { cause?: unknown; details?: unknown; reason?: ChordErrorReason }, ) { super(`${code}${options?.reason ? `/${options.reason}` : ""}: ${message}`, { cause: options?.cause, }); this.name = "ChordError"; this.code = code; this.reason = options?.reason; this.retryable = RETRYABLE.has(code); this.details = options?.details; }}ChordError ném từ factory hay từ một phụ thuộc giữ nguyên mã qua mọi bên phụ thuộc; lỗi khác thành unavailable với lỗi gốc làm cause (errors.ts:56–62).Qua link, lỗi đi dưới dạng { code, message, reason } và được dựng lại thành ChordError ở phía nhận (wire.ts:171–181). Bảng nghĩa từng mã nằm trong guide (guide.md:784–799); hai mã đáng nhớ: superseded (code version của root không còn trên host này) và upgrade_conflict (một root cũ chạm vào object vừa được migrate), cả hai thử lại được bằng một root mới.
Hot reload: prepare, gate, commit, retire#
Lời hứa của README: hot reload thay code trong lúc chạy; việc đang bay xong trên code nó đã bắt đầu, state được giữ, và thay đổi không tương thích được migrate ở một điểm an toàn (README.md:9). Spec coi đó là ràng buộc cứng: chỉ khởi động lại host khi chính Chord đổi, hoặc khi phục hồi sau crash (spec.md:8). Bài blog của Armin Ronacher về Pi nhấn mạnh hot reload để agent tự viết và thử extension của mình ("Pi: The Minimal Agent Within OpenClaw"); Chord đưa ý đó lên mức một runtime nhiều tiến trình.
Nạp module mới
// Node facet loader: `createHost({ loader: nodeLoader })`. Kept out of index.ts so the core stays browser-safe.//// TODO(loader): replace with an isolating, evicting loader. Every reload imports fresh module copies under a new// query and the old copies are never evicted (they leak until the process exits). TUI plugins need isolation, which// this loader does not provide.export const nodeLoader: FacetLoader = async (modules) => { hook(); const version = String(++generation); const facets: Facet[] = []; const urls = modules.map((module) => { const url = toUrl(module); url.search = ""; return url; }); // Every root first: a facet importing another facet's directory gets this load's copy, whatever the order. for (const url of urls) roots.add(new URL(".", url).href); for (const url of urls) { const name = url.href; url.searchParams.set(PARAM, version); // Owner-approved exception to the no-dynamic-import rule; see TODO(loader) above. const loaded = (await import(url.href)) as { default?: FacetDefinition }; const definition = loaded.default; if (definition === undefined || typeof definition.setup !== "function") { throw new TypeError(`${name}: default export must be a facet (defineFacet)`); } facets.push({ name, setup: definition.setup, uses: definition.uses }); } return facets;};registerHooks mang query đó xuống các import tương đối bên trong thư mục của facet, còn Chord và code dùng chung vẫn là một bản duy nhất (loader.ts:17–36). Bản cũ của module không bao giờ được giải phóng.Năm bước
- LoadChạy
setupcủa các facet mới vào một bảng service ứng viên; tính các loại đã đổi và đã bị gỡ; cấp phát code version kế tiếp ở trạng tháipending(reload.ts:278–303). - PrepareChạy factory mới trên mọi object đang sống của loại đã đổi, ở chế độ ghi lại: đọc được cell, ghi cell thì ném lỗi, listener và task chưa chạy, resource đổi version được mở cạnh bản cũ. Kiểm contract với mọi consumer còn sống. Lỗi bất kỳ: mọi thứ giữ nguyên.
- GateChỉ khi một cell tăng version, hoặc một resource độc quyền (exclusive) bị thay: chặn root cũ mới vào, chờ root đã vào xong, migrate vào bản sao riêng.
- CommitTrong một bước đồng bộ: cài code mới, đổi bảng registry, khởi động lại task, gắn lại listener, gửi
resetvà shape mới cho người quan sát. - RetireCode cũ tiếp tục chạy các root nó đã bắt đầu; khi chúng xong, nó bị bỏ cùng những gì chỉ nó khai báo.
Đây là tóm tắt của guide (guide.md:630–636); code của prepare() đi đúng thứ tự đó:
/** Everything up to the commit. On failure the host is rolled back and the underlying error is thrown. */async prepare(): Promise<void> { try { await this.waitForRetirement(); this.load(); this.checkProviders(); await this.prepareAll(); // From here new objects of changed types are rejected (upgrade_conflict, retryable) until commit or abort, // so the candidate set is final once the activations already in flight have been prepared. this.host.gating = this.changed; await this.withDeadline(this.prepareAll(), "activations in flight did not finish"); this.checkRefs(); this.tables = this.host.buildTables(this.entries()); await this.gate(); this.#alive(); this.#stage(); } catch (error) { await this.rollback(); throw reloadError(error); }}prepareAll() chạy hai lần: lần hai sau khi bật gating, để bắt cả các object đang activation dở. Từ lúc đó, tạo object mới của một loại đã đổi bị từ chối upgrade_conflict (thử lại được) cho tới commit hoặc abort.Mỗi cell mang version của riêng nó, và luật được thực thi ngay khi prepare một object:
// Each cell carries its own version: a raised one gates and migrates, a schema change without one is refused.for (const [name, cell] of run.decls.cells) { if (object.decls.cells.get(name) !== cell) continue; const from = object.decls.cellVersions.get(name) ?? 1; const to = run.decls.cellVersions.get(name) ?? 1; if (to < from) throw invalid("state", `${object.address}: version of state ${name} lowered from ${from} to ${to}`); if (to > from) candidate.reasons.push(`state ${name} version ${from} -> ${to}`); else if (!deepEqual(cell.schema, run.decls.cellSchemas.get(name))) throw invalid("state", `${object.address}: schema of state ${name} changed without a version bump`);}Gate và migrate
Bài toán: một root cũ đang giữa chừng đọc state theo hình dạng cũ; nếu migrate ngay dưới chân nó, code cũ sẽ chạy trên dữ liệu mới. Gate giải bằng một lát cắt nhận vào (admission cut): root đã chạm vào object (có lease) được "miễn trừ" (grandfathered) và được vào lại; root cũ chưa chạm thì nhận upgrade_conflict để thử lại như root mới. Task của object bị dừng ở lát cắt.
async gate(): Promise<void> { this.gated = [...this.candidates.values()].filter( (candidate) => candidate.reasons.length > 0 && candidate.object.status === "live", ); const drained = this.gated.map(({ object }) => { const grandfathered = new Set(object.leases.keys()); return new Promise<void>((resolve) => { object.gate = { grandfathered, queue: [], drained: resolve }; object.stopTasks(); if (grandfathered.size === 0) resolve(); }); }); if (timedOut) { const blocking = this.gated.flatMap(({ object }) => [...(object.gate?.grandfathered ?? [])]); throw new ChordError( "aborted", `gate deadline (${deadline} ms) missed; blocking roots: ${blocking.join(", ")}`, { reason: "gate_deadline", details: { blocking }, }, ); }gateDeadline (mặc định 5 s) thì cả reload bị huỷ và không có gì thay đổi; lỗi mang tên các root đang chặn trong details. Sau đó resource độc quyền được thay (đóng cũ, mở mới, mở lại cũ nếu mới lỗi), rồi migrate(structuredClone(cell.value), from) chạy trên một bản sao riêng và kết quả được kiểm theo schema mới (reload.ts:505–521).Tutorial minh hoạ cả hai trường hợp: đổi nhãn (tương thích) không phải chờ, root cũ đang tạm dừng vẫn thấy "Original" hai lần trong khi lời gọi mới thấy "Updated" (tutorial.md:651–684); đổi nghĩa của rows (viết hoa) với version: 2 và migrate thì đi qua gate, và subscriber nhận snap rồi reset (tutorial.md:694–727).
Commit: mọi thứ có thể lỗi đã xảy ra trước đó
/** * Publishes the new code version in one synchronous step. Everything fallible ran in prepare (factories, opens, * migration and derivation checks against the prospective schemas, listener subscriptions), so this only installs. * Observers (state, shapes, registries, listener roots) are notified after the whole step. If an install still * throws, the code version is half-published: the host fails terminally and admits no more work. */commit(beforeStart?: () => void): ReloadReport { try { return holdPublications(() => { const report = this.#publish(); // Installed, not yet started: the coordinator queues its commit frames here, so nothing this code version // starts (tasks, host object activations, then the held listener/registry/state notifications) can reach a child // first. beforeStart?.(); for (const start of this.#starts.splice(0)) start(); return report; }); } catch (error) { this.host.fail(error); throw reloadError(error); }}#stage() đăng ký listener và đệm sự kiện). Commit chỉ cài. Nếu một bước cài vẫn ném lỗi, code version bị công bố dở dang: host chuyển sang hỏng vĩnh viễn và từ chối mọi việc mới.#publish() đổi previous thành code version đang chạy và current thành bản mới, cài implementation mới cho các object đã prepare, chép implementation cũ sang id mới cho object không đổi, cài bảng registry, ép huỷ object của loại bị gỡ, và đẩy việc khởi động task cùng tín hiệu facet ra sau (reload.ts:587–656). install() của một object được gate đặt minCodeVersion và bỏ ngay implementation cũ, vì code cũ không bao giờ được chạy trên state đã migrate; cell được migrate nhận reset; reference của trình duyệt mà contract không còn bind được nhận upgrade_required (reload.ts:658–722).
Luật hai phiên bản và retire
Một host chỉ giữ hai code version: current và previous. Lời gọi mang code version không còn được giữ bị từ chối superseded, không bao giờ được chạy bằng phiên bản khác (spec.md:198). previous được retire khi không còn root nào của nó (và, ở host con, khi cha đã retire nó) (host.ts:701–754). Reload kế tiếp chờ điều đó tối đa retireDeadline; quá hạn thì huỷ các root còn chạy (lý do superseded), cho chúng một giây, rồi retire bất kể (reload.ts:259–276).
Registry cũng theo code version: mỗi code version có một bảng hoàn chỉnh, registry(R).get(key, ctx) đọc bảng của ctx.codeVersion, nên một request bắt đầu trước reload vẫn thấy bảng cũ và không có lúc nào một mục bị thiếu (host.ts:787–809; guide.md:575–578). Spec ghi rõ Chord không kèm cây cầu sang RegistryReader của Durable; ứng dụng tự dựng nó từ bảng (spec.md:230).
Reload cả cụm
Trên server có host con, host.reload(serverFacets, { remote: { "session:": workerFacets } }) thành một commit hai pha (cluster.ts:7–14): server retire bản trước (frame retire tới con trước prepare nhờ link FIFO), prepare chính nó và gửi prepare cho mọi con đang đồng bộ, gom phiếu (vote) có hạn chót; một lỗi hay một phiếu trễ thì gửi abort mọi nơi; đủ phiếu thì commit ở server, gửi commit cho các con trước khi khởi động bất cứ thứ gì của code mới (cluster.ts:39–115). Một host con kết nối khi đang ở sau code version của cha bị cách ly (fenced) và được hoà giải (reconcile) bằng chính giao thức đó (cluster.ts:117–158). Spec liệt kê giới hạn: quyết định commit không được lưu, nên server crash giữa commit của nó và của con để con ở code cũ cho tới khi kết nối lại; chỉ một tầng con tham gia (spec.md:256–259).
Tiến trình host con chạy dưới createHostManager() của /node: fork với detached để con dẫn một process group và cả cây bị giết khi con thoát, một kênh điều khiển chung IPC với frame Chord (ready, fatal, status, shutdown), và một khoá tiến trình do kernel giữ (abstract socket trên Linux, named pipe trên Windows) mà con lấy trước khi mở storage (manager.ts:76–123; manager.ts:164–229).
Link, wire frame và connection#
Một link là kênh frame hai chiều, có thứ tự, giữa hai host (hoặc một host và một client trình duyệt). Nó không biết gì về object hay state; mọi ngữ nghĩa nằm ở Connection, một đầu của link trên mỗi host.
/** An ordered (FIFO), bidirectional frame channel between two hosts. */export interface Link { readonly closed: boolean; /** * Returns the size of the encoded frame as sent (JSON text length, or bytes of a packed message) when the transport * knows it. The connection's flow control counts these sizes; frames without one count as zero, so a link that never * reports sizes is not windowed. */ send(frame: Frame): number | undefined; /** Sets the single receiver. Frames arriving earlier are queued. */ receive(handler: (frame: Frame) => void): void; onClose(handler: (reason: string) => void): void; /** Closes both ends: the peer gets a `bye`. */ close(reason?: string): void;}send() trả về kích thước frame đã mã hoá khi transport biết nó; flow control đếm các kích thước này, nên một link không báo kích thước (IPC) không bao giờ bị giới hạn cửa sổ.Ba transport đi kèm. memoryLinkPair() cho test, nhưng vẫn dùng đúng giao thức: mỗi frame đi qua một vòng JSON.stringify/parse để tách khỏi bộ nhớ của bên gửi và để lộ những thứ JSON sẽ làm hỏng (như bigint), và giao không đồng bộ theo một chuỗi promise mỗi chiều (link.ts:242–269). ipcLink(process) bọc frame thành { chord2: frame } trên kênh IPC của child_process, để chung chỗ với tin điều khiển (link.ts:296–337). webSocketLink(socket) chạy trên bề mặt WebSocket chuẩn WHATWG, nên Chord không phụ thuộc thư viện ws; frame gửi trước khi socket mở được giữ trong outbox (link.ts:361–412). Phía server, acceptWebSockets() bọc socket ngay (để không mất frame trong lúc xác thực), gọi hàm xác thực của ứng dụng, rồi nhận link như một client với principal đã đóng dấu; không xác thực được thì đóng (websocket.ts:17–35).
Payload lớn (tham số, kết quả, ảnh chụp state có JSON dài hơn 64 KiB ký tự) được deflate. Host chỉ chuyển tiếp frame thì giữ nguyên bytes đã nén, chỉ host dùng payload mới giải nén (link.ts:105–113). Server nên từ chối frame nén từ client không tin cậy, vì một tin nhỏ có thể giải nén ra bất cứ kích thước nào (websocket.ts:24–26).
Frame
// JSON frames between two hosts (or a host and a client). Ids are chosen by the side that creates the hold, call or// subscription; every frame type has one direction, so ids never collide:// consumer → provider: hold, release, call, cancel, sub, unsub, resync// provider → consumer: shape, gone, result, state// State is addressed by (hold id, member): a hold implies the state of its variant, sent as units (a shape followed by// a snapshot of each member the consumer lacks; see Connection).// both: hello, ping, pong, bye, root_end, ack; parent → child: invalidate, prepare, commit, abort, retire;// child → parent: vote//// Each frame is declared once, as the schema every received frame is checked against (`isFrame`); the types are derived// from it. Payloads (hold inputs, call args, results, snapshots) stay `unknown`: they are validated where they are// used, against the contract. State ops are checked op by op by the delta decoder./** * The link protocol version, announced in `hello`. Peers of different versions do not talk: the side that notices * closes the link with reason `protocol-version <its version>`, so the other side learns it too * (`LinkOptions.onProtocolMismatch`). */export const PROTOCOL_VERSION = 4;isFrame); kiểu TypeScript suy ra từ schema. Payload (input, tham số, kết quả, ảnh chụp) để unknown và được kiểm ở nơi dùng theo contract.Không có frame get riêng: một reference qua link là một hold, được trả lời bằng shape hoặc gone (spec.md:239). Frame hold mang host đích at, token contract của consumer, input đã chuẩn hoá, và có thể principal chuyển tiếp, origin, state: false, again (wire.ts:73–96). State đi trong frame state, đánh địa chỉ bằng id của hold và tên member:
Type.Object({ t: Type.Literal("state"), hold: Id, m: Name, /** `snap`/`reset` without `value`: the value is `undefined`. */ as: Type.Union([Type.Literal("snap"), Type.Literal("reset"), Type.Literal("ops"), Type.Literal("error")]), value: Type.Optional(Type.Unknown()), /** Encoded with the stream's delta encoder; the decoder validates each op. */ ops: Type.Optional(List(List(Type.Unknown()))), error: Type.Optional(WireErrorSchema),}),Hai đầu công bố phiên bản giao thức trong hello; khác phiên bản thì không nói chuyện: bên phát hiện gọi onProtocolMismatch và đóng link với lý do protocol-version <n>, để bên kia cũng biết (connection.ts:832–859). Không thương lượng: bên cũ phải nạp code mới, trình duyệt thì tải lại trang (guide.md:747–753). Frame nhận được mà sai schema, trùng id, hay là frame điều khiển từ sai phía, thì đóng link (connection.ts:665–669).
Principal được đóng dấu, không được khai
/** * Principal stamping: a downstream link acts as the principal it was accepted with, whatever the frame says. Only * the parent may forward a principal (relayed requests); its own requests act as the principal given to `connect`. */#principalOf(frame: { readonly principal?: unknown }): unknown { if (this.role === "downstream") return this.principal; return frame.principal ?? this.principal;}/** * The client connection a hold comes from: this link's own id for a client link (whatever the frame says), the id * the parent forwarded for a relayed hold, none for a host's own holds. */#originOf(frame: { readonly origin?: string }): string | undefined { return this.role === "downstream" ? this.originId : frame.origin;}Phía provider: unit, feed và encoder theo luồng
Khi nhận hold, connection kiểm placement của input với tên host mình, gọi acquire() với principal đã đóng dấu, và đăng ký nghe shape của reference (connection.ts:878–929). Khi reference sống, nó gửi một unit: frame shape liệt kê các member state mà consumer còn thiếu, tiếp theo là một frame state as: "snap" cho mỗi member đó (connection.ts:956–983). Mỗi member có một feed: một subscription events() trên member của reference, và một encoder riêng. Sự kiện đến trong lúc ảnh chụp đang được dựng được đệm lại và phát sau ảnh chụp (connection.ts:985–1012). Sau đó:
/** A later event of a member's stream: ops are encoded, a reset starts a new stream. */#feedEvent(served: ServedLocal, name: string, feed: Feed, event: StateEvent<unknown>): void { if (served.feeds.get(name) !== feed) return; const overflow = { resend: () => this.#resend(served, name) }; if (event.type === "ops") { this.sendState( { t: "state", hold: served.id, m: name, as: "ops", ops: feed.encoder.encode(event.ops) }, overflow, ); return; } feed.encoder = encoder(); this.sendState(this.#snapshot(served, name, "reset", event.value), overflow);}Phía consumer: một binding cho mỗi object ở xa
Mọi reference của một host tới cùng một object ở xa (cùng loại, cùng input, cùng principal key, cùng fingerprint contract) là các view của một RemoteBinding: một hold qua link, một luồng cho mỗi member state, lời gọi đi chung hold đó (host.ts:1084–1109). Binding áp một shape chỉ khi mọi ảnh chụp của nó đã tới, nên một binding live luôn có mọi member state của variant (remote.ts:364–383). Frame state đi vào đây:
/** A state frame for member `name` of the current hold. */state(name: string, frame: StateIn): void { if (this.ended) return; if (frame.as === "error") { const error = fromWireError(frame.error!); // Before the first hydration nobody could read anything: the binding fails instead of waiting forever. if (!this.hydrated && this.stateOn) { this.gone(error); this.close(); } else this.host.report(error); return; } const pending = this.#pending; if (frame.as === "ops") { const target = pending?.values.get(name) ?? this.members.get(name); if (target === undefined) return; const ops = target.decoder.decode(frame.ops ?? []); target.value = applyImmutable(target.value, ops); if (target === this.members.get(name)) this.#deliver(name, { type: "ops", ops, value: target.value }); return; } const fresh: MemberState = { value: frame.value, decoder: decoder() }; if (pending !== undefined && (pending.missing.has(name) || pending.values.has(name))) { pending.values.set(name, fresh); pending.missing.delete(name); if (pending.missing.size === 0) this.#apply(); return; } if (!this.inVariant(name)) return; this.members.set(name, fresh); this.#deliver(name, { type: "reset", value: fresh.value });}Lời gọi đi như frame call trên hold; client (trình duyệt) không bao giờ mang root hay code version, server mở root cho chúng (connection.ts:700–729). Phía nhận có thể giới hạn số lời gọi đang chạy của một peer (maxCalls); lời gọi vượt trần được trả lời ngay unavailable/too_many_calls và link vẫn mở (connection.ts:1100–1147).
Flow control: chỉ giữ lại state
* Flow control* ------------** Problem: state is pushed as fast as it changes, but a peer may read slowly (a phone behind a relay), and a relay may* hide that: Cloudflare forwards to browsers without backpressure. Everything sent then queues in front of the next* call result. Example: a browser opens three long transcripts (500 KiB of snapshots each) over a 5 Mbit/s path; a* result sent next waits 2.4 s, and the snapshot of a transcript the user already left is still sent in full.** Solution: the peer acknowledges what it processed (`ack`, a cumulative frame count), and the sender counts the sizes* of the frames not yet acknowledged. While they exceed the window, state waits here in slots, flushed in creation* order as acknowledgements come:* - a member's stream has one slot: ops are appended (encoded batches of one stream may be concatenated, see* `encoder`), a snapshot, reset or error replaces it;* - a hold's unit has one slot, built lazily from the current values when it is sent, so a newer unit merges into it;* while it waits, the members it introduces have no stream yet.** The first unit of a hold that is not a re-hold (`again`) is sent at once, past the window: a `get` waits for it, and* must not wait behind other holds' state. Results, shapes, gone, reload control and pings are sent at once too: they* are bounded by the requests the peer makes. Hence the one ordering exception of a link: while state waits, a result* can arrive before state changes made before it. Frames of one stream stay in order.Cụ thể: hai đầu công bố acks trong hello và gửi frame ack mang số frame đã xử lý cộng dồn, một lần cho mỗi đợt frame đến chứ không mỗi frame (connection.ts:611–619). Khi số byte chưa được xác nhận vượt cửa sổ (mặc định 256 KiB), frame state chờ ở bên gửi trong các ô (slot): mỗi luồng member một ô, ops mới được nối vào, ảnh chụp hay reset thay cả ô (connection.ts:468–506). Nối được là nhờ tính chất của encoder: các lô liên tiếp của cùng một encoder ghép thành một lô được, vì mỗi lô bắt đầu bằng một đường dẫn tường minh (delta/index.ts:547–549). Ops chờ có trần theo link (pendingCap, mặc định bốn cửa sổ); vượt trần thì các luồng cũ nhất bị thay bằng một reset dựng từ giá trị hiện tại lúc xả, nên bộ nhớ bị chặn dù peer dừng bao lâu (connection.ts:508–529). Unit đầu tiên của một hold mới, kết quả, shape, gone và ping thì luôn gửi ngay. Hệ quả duy nhất về thứ tự: trong lúc state chờ, một kết quả có thể tới trước thay đổi state đã xảy ra trước nó.
Relay, route và mất kết nối
Server chuyển tiếp hold từ trình duyệt tới worker, và giữa các worker. Relay cần một route, vì đó là nơi đặt phân quyền: authorize(principal, hostName) chạy trước khi resolve (có thể sinh tiến trình mới) và chạy lại sau đó; một lần thu hồi principal trong lúc resolve thắng (host.ts:1139–1185; connection.ts:124–129). Link ping mỗi 15 s và đóng sau 45 s im lặng; một link chết nhả mọi thứ giữ qua nó (connection.ts:12–16). Link không tiếp tục được (resume): provider nhả mọi thứ ngay khi link đóng, nên hold lại sau khi kết nối lại là object mới; lời gọi đang bay khi link rớt bị từ chối unavailable và không biết đã chạy hay chưa (spec.md:260). Phía consumer, binding giữ giá trị cuối và trạng thái loading trong lúc hold lại qua link mới (remote.ts:304–314).
Lần theo một thay đổi state qua link#
Kịch bản là ví dụ §6 của tutorial, chạy thật trong test/tutorial.test.ts: một host server phục vụ TodoStore (local, giữ cell rows) và view remote Todos; một host browser kiểu client nối lên qua memoryLinkPair(); trình duyệt lấy reference tới Todos, đăng ký rows, rồi gọi add.
const server = createHost<Principal>({ self: "server", name: "server" });server.serve(TodoStore, todoFactory);serveViews(server);const [down, up] = memoryLinkPair();server.accept(down, { principal: "owner", kind: "client" });const browser = createHost({ self: "browser", name: "browser", kind: "client", upstream: { link: up, principal: "server" },});const todos = await browser.get(Todos, { list: "family" });assert.ok(todos.variant === "editor");assert.deepEqual(todos.rows.value, []);const updated = Promise.withResolvers<void>();const stop = todos.rows.subscribe((rows) => { if (rows.length === 1) updated.resolve();});await browser.root((ctx) => todos.add({ text: "Buy milk" }, ctx));await updated.promise;assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]);Todos trả về rows: store.rows, tức member state của reference tới TodoStore (tutorial.md:355–371); TodoStore.add là rows.change(ctx, (draft) => { draft.push({ text, done: false }); }) (tutorial.md:153–157).Các bước dưới đây là đường đi trong code, đọc theo thứ tự; giá trị cụ thể của phép toán và frame là suy ra từ code, không in ra từ một lần chạy.
- 1. Kết nốiMỗi
Connectiongửihellongay khi được tạo, kèmprotocol: 4vàacks: true(connection.ts:431–438). Server đã quyết định link này làclient, hành động như"owner"; hello của trình duyệt phải khớp. - 2. browser.get(Todos)
acquire()thấy placementhostNamed("server")khác tên host mình, nên tạo mộtRemoteBindingvà giữ nó qua link lên cha (host.ts:1111–1129). Frameholdkhông mang principal: principal của chính host bị lược, và server đằng nào cũng không tin nó. - 3. Server nhận hold
#onHoldkiểm placement, rồiacquire(token, input, { principal: "owner" }, "client"). ObjectTodoschưa có, nên activation chạy factory, factory khai báoself.get(TodoStore), vàTodoschỉ live khiTodoStoređã live. Factory thấy principal không phải"guest"nên chọn varianteditor. - 4. Unit đầu tiênReference live,
#unitgửi ngay (unit đầu không chờ cửa sổ): frameshapevớistate: ["rows"], rồi framestateas: "snap",value: []. Ảnh chụp lấy từ sự kiệnsnapđầu tiên củaevents(), và feed được gắn trong cùng bước nên không lỡ thay đổi nào (connection.ts:989–1005). - 5. Trình duyệt hydrate
shaped()ghi nhận shape đang chờrows; frame snap tới,#apply()cài shape, đặthydrated = true, statuslive;getresolve.todos.rows.subscribe()gọi ngay listener với[]. - 6. Lời gọi add
RemoteBinding.call()làm sạch tham số theo schema của consumer và gửicall; host kiểu client không mang root (connection.ts:724–727). Server mở một root cho nó, hành động như"owner", vàObjectImpl.call()kiểm{ text: "Buy milk" }theo schema của provider. - 7. Draft và phép toán
Todos.addgọistore.addvới ctx của nó.Cell.change()mở draft trên{ v: [] };draft.push(...)sao chép object vào một piece chèn và đánh dấu nút mảng bẩn.prepare(): nút bẩn là mảng nên không đi đường "chỉ object"; đường dẫn của nút là["v"]; kế hoạch có thứ tự cho một piece chèn vào mảng rỗng là["p", ["v"], 0, 0, [{ text: "Buy milk", done: false }]]. - 8. Công bố
#publish()tính lạiopenCount(0 thành 1), rồi trongholdPublicationsadopt, gỡ hộp thành["p", [], 0, 0, [...]]và xếp sự kiệnopsvào hàng publication. Hàng được xả ngay khiholdPublicationstrả về, vẫn trong cùng lời gọi đồng bộ. - 9. Hai lớp MemberReadableSự kiện đi qua
MemberReadable("rows")của reference tớiTodoStore, rồi quaMemberReadable("rows")của reference tớiTodosmà feed đang nghe; mỗi lớp xếp việc giao vào cùng hàng FIFO. - 10. Lên dây
#feedEventmã hoá bằng encoder của luồng; đường dẫn[]dùng lần đầu nên viết thẳng. Cửa sổ chưa đầy nênsendStategửi ngay;MemoryLinkchuyển frame qua một vòng JSON. - 11. Phía trình duyệt
RemoteBinding.state(): decoder của luồng kiểm và dựng lại phép toán,applyImmutable([], ops)đi nhánh append một lần chép, rồiViewReadable.deliver()gọi listener với[{ text: "Buy milk", done: false }]. Trình duyệt hẹn mộtack. - 12. Kết quảMethod của server xong sau đó (một promise), frame
resultkhông mangvaluevìaddkhông khai báo kết quả;browser.root(...)resolve.
Ví dụ minh hoạ: các frame trên link (id, fingerprint và rootId là giả định; ack và ping bị lược)
browser → server {"t":"hello","protocol":4,"name":"browser","kind":"client","codeVersion":1,"acks":true}
server → browser {"t":"hello","protocol":4,"name":"server","kind":"host","codeVersion":1,"acks":true}
browser → server {"t":"hold","id":1,"at":"server","token":{"name":"tutorial.todo-view","scope":"remote",
"version":1,"input":{…},"host":{"name":"server"},"variants":{…}},"input":{"list":"family"}}
server → browser {"t":"shape","id":1,"variant":"editor","members":["rows","add"],"contract":"9f3c…","state":["rows"]}
server → browser {"t":"state","hold":1,"m":"rows","as":"snap","value":[]}
browser → server {"t":"call","id":2,"hold":1,"member":"add","args":{"text":"Buy milk"}}
server: root mới, principal "owner" · Cell.change · ["p",["v"],0,0,[…]] → unbox
server → browser {"t":"state","hold":1,"m":"rows","as":"ops","ops":[["p",[],0,0,[{"text":"Buy milk","done":false}]]]}
server → browser {"t":"result","id":2}
rows.change() còn kết quả đi sau một promise. Khi cửa sổ đầy thì ngược lại có thể xảy ra, nên tutorial chờ updated.promise thay vì đọc state ngay sau khi lời gọi trả về (tutorial.md:827–828).Ba điều kịch bản này cho thấy. Thứ nhất, không bên nào diff lại: phép toán sinh một lần từ draft trên server, được gỡ hộp, mã hoá, giải mã và áp nguyên trạng ở trình duyệt. Thứ hai, danh tính đến từ link: trình duyệt không nói nó là ai, server đóng dấu "owner" lúc accept, và chính principal đó chọn variant editor. Thứ ba, có hai lớp kiểm: tham số được kiểm ở ranh giới provider, còn ảnh chụp và reset được kiểm theo schema mà consumer được hứa trước khi gửi.
Pi Durable dùng Chord thế nào#
Gom mọi câu import từ @earendil-works/chord trong packages/durable/src (51 file) ra được một danh sách ngắn, và nó chỉ chạm tầng nền. Không có createHost, remote, local, link hay reload nào.
| Từ Chord | Nơi Durable dùng | Để làm gì |
|---|---|---|
Context, BACKGROUND_CONTEXT, withAbortSignal, withoutAbortSignal, awaitWithContext, withTelemetry | Tham số cuối của mọi API bất đồng bộ (README.md:78); span của mỗi phase (scheduler.ts:1762–1763) | Huỷ một lần chờ, không huỷ công việc; truyền span xuống phase và tool |
JsonValue, JsonRepresentation, copyJson, Draft | types.ts, harness/tool.ts...; tx.doc() trả về Draft<T> (types.ts:970) | Kiểu JSON nghiêm ngặt, sao chép giá trị vào bản ghi |
track, Tracker, Change, Prepared, Op | session/transaction.ts, session/session.ts | Một tracker cho mỗi document đã nạp; prepare khi callback của commit xong; prepared.ops thành bản ghi delta; adopt sau khi storage ghi xong |
apply, applyImmutable, applyImmutableBatches | Backend SQLite, IndexedDB, Memory; harness/view.ts, harness/task-graph.ts | Đọc base cộng chuỗi delta; áp ops của commit lên view đang mount |
replicatedState, AttachedReplicatedState, ReplicatedStateSource… | session/observation.ts; session.ts:372, view.ts:118, task-graph.ts:116 | documentState(), viewState(), taskGraph() trả về state Chord chỉ đọc |
overlap, OutputBuffer, Progress, boundOutput, PROGRESS_BYTES_PER_SECOND | harness/tool.ts:1–11 | Output có giới hạn của tool đang chạy, nhịp commit tiến độ |
SqliteDatabase, applySqliteMigrations, validateSqliteSchemaPrefix, openNodeSqliteDatabase, DurableObjectSqliteDatabase | storage/sqlite/* | Facade và driver SQLite; Durable vẫn tái xuất các tên cũ (storage/sqlite/node.ts:5–9) |
Document: draft của Chord, commit của Durable
Mỗi document đã nạp là một Tracker<JsonObject> trong bộ nhớ đệm của Session (transaction.ts:87–98). tx.doc(token) mở tracker.beginChange() và trả về change.state làm draft. Khi callback của commit xong, transaction prepare đồng bộ mọi change đang mở (việc này thu hồi mọi draft), lập kế hoạch ghi: phiên bản định nghĩa đổi thì lưu một base, có phép toán thì lưu một delta { kind: "delta", ops: prepared.ops }, không có gì đổi thì không ghi (transaction.ts:1037–1053). Chỉ sau khi storage ghi thành công, adopt() mới đổi con trỏ; ghi lỗi thì discard() huỷ mọi preparation, tracker giữ revision cũ, và Session chuyển sang hỏng vì lỗi storage là lỗi chết của nó (durable/session/session.ts:747–758). Đây đúng là lý do prepare() và adopt() tách nhau trong tracker: bộ nhớ không bao giờ đi trước storage.
Quan sát: không diff lại ở đâu cả
Sau mỗi commit, Session đưa giá trị bất biến mới và đúng lô phép toán của commit đó cho các nguồn đang gắn. CommittedStateSource biến chúng thành frame có cursor cho replicatedState(source); CommittedWatch giao (value, ops, context) cho watch(). View của hội thoại ghép phép toán của nhiều document bằng cách dời đường dẫn xuống ["docs", kind] và thêm splice cho entry mới, rồi áp lên giá trị đang mount bằng applyImmutable:
if (change.type !== "document" || change.conversationId !== id) continue; const kind = change.record.kind; if (!MOUNTED_KINDS.has(kind) || change.record.key !== undefined) continue; const path: NonEmptyPath = ["docs", kind]; const mounted = mount.docs.get(kind); if (change.value === null) { if (mounted?.id !== change.record.id) continue; mount.docs.delete(kind); docOps.push(["d", path]); } else if (mounted?.id === change.record.id && mounted.version === change.version) { for (const op of change.ops) docOps.push(prefixed(op, path)); } else { mount.docs.set(kind, { id: change.record.id, version: change.version! }); docOps.push(["s", path, change.value]); }}const before = mount.value;const ops = [...docOps, ...entryOps];const frameContext = withoutAbortSignal(context);if (ops.length > 0) { const value = docOps.length === 0 ? mount.value : applyImmutable(mount.value, docOps); mount.value = entries === value.entries ? value : { ...value, entries }; for (const observer of [...mount.observers]) { contained(() => observer.advance?.(mount.value, ops, frameContext), report); }s), document bị retire thì bị xoá (d). Đây là ví dụ mà chương mô hình Durable trích từ spec: ["s", ["generation", "message"], v] trở thành ["s", ["docs", "pi.live", "generation", "message"], v].Một ứng dụng muốn đẩy view của Durable cho trình duyệt có hai đường: gói nó trong một object Chord và để link lo phần còn lại (cách guide gợi ý), hoặc tự gửi (value, ops) của watch() qua giao thức của mình và áp bằng applyImmutable ở đầu kia. Repo ở v1.4.0 không chứa ví dụ nào cho đường thứ nhất.
Những mảnh Durable từng sở hữu
Hai lần chuyển code từ Durable sang Chord cho thấy ranh giới mà nhóm muốn: OutputBuffer và Progress rời Durable sang Chord ở 1.3.0 (CHANGELOG.md:40); các driver SQLite, giao diện và bộ chạy migration chung sang Chord ở 1.4.0, còn helper storage và migration lược đồ của Durable ở lại (durable/CHANGELOG.md:35). Thứ gì không biết gì về hội thoại, entry hay task thì về Chord.
Output, SQLite và telemetry-sqlite#
Ba mảnh còn lại của tầng nền đều là thứ Durable từng tự giữ, hoặc cần chung với ứng dụng.
OutputBuffer và Progress
Output của một tool hay một tiến trình có thể dài vô hạn, trong khi thứ được lưu và gửi cho mô hình phải có giới hạn. OutputBuffer giữ cửa sổ đầu (head) hoặc cuối (tail) của một luồng với chi phí tỉ lệ với từng chunk: chế độ đầu ngừng lưu khi cửa sổ đã đầy, chế độ cuối bỏ những chunk đầu mà cửa sổ không còn cần, và tổng byte, dòng của cả luồng vẫn được đếm để báo chính xác phần đã bỏ (output.ts:122–217). snapshot() cắt theo dòng nguyên trên ranh giới ký tự UTF-8 và bỏ ký tự điều khiển làm hỏng hiển thị (output.ts:39–59). Phía Durable, harness/tool.ts gán output vào slot như một trường chuỗi duy nhất, để tracker biến một cửa sổ đuôi trượt thành một cặp t + a thay vì ghi lại cả cửa sổ; nó chỉ dùng overlap() để ước số byte một commit sẽ ghi, làm đầu vào cho Progress (durable/harness/tool.ts:493–509).
/** * Adaptive progress commits: the first change after an idle period commits at once; each commit then delays the next * by at least `minIntervalMs` and by its written size at 100 KiB/s. At most one commit is in flight; changes made * meanwhile coalesce into the next one. Paces on `clock`, by default `Date.now` and `setTimeout`. */export class Progress { readonly #write: () => Promise<number>; readonly #onError: (error: unknown) => void; readonly #minIntervalMs: number; readonly #clock: ProgressClock; #waiters: PromiseWithResolvers<void>[] = []; #timer: (() => void) | undefined; #inFlight: Promise<void> | undefined; #nextAt = 0; #dirty = false; #stopped = false; #flush(): void { if (this.#stopped || !this.#dirty) return; this.#dirty = false; const waiters = this.#waiters.splice(0); const started = this.#clock.now(); this.#inFlight = this.#write() .then( (bytes) => { this.#nextAt = started + Math.max(this.#minIntervalMs, (bytes * 1000) / PROGRESS_BYTES_PER_SECOND); for (const waiter of waiters) waiter.resolve(); }, (error: unknown) => { this.#nextAt = started + this.#minIntervalMs; for (const waiter of waiters) waiter.reject(error); this.#onError(error); }, ) .finally(() => { this.#inFlight = undefined; if (this.#dirty) this.#schedule(); }); }write() trả về số byte đã ghi; lần kế sớm nhất là max(minIntervalMs, bytes / 100 KiB/s) sau lúc bắt đầu commit trước. Chỉ một commit bay cùng lúc, thay đổi trong lúc chờ gộp vào commit kế. Chương task của Durable cho thấy nhịp này áp vào output của tool (mặc định 100 ms).SQLite di động
Một facade bất đồng bộ đủ nhỏ để cài đặt trên node:sqlite (đồng bộ), trên ctx.storage.sql của Durable Object (đồng bộ, không có bước prepare), hay trên một adapter chạy ngoài tiến trình:
/** * Asynchronous SQL operations shared by a database and its transaction handles. * * `exec` runs SQL text without bindings and may contain several statements. `run`, `get`, and `all` * execute one statement with positional bindings. Adapters may cache prepared statements by SQL text, * so callers pass values as bindings instead of interpolating them. */export interface SqliteExecutor { exec(sql: string): Promise<void>; run(sql: string, ...params: SqliteValue[]): Promise<void>; get<T extends object>(sql: string, ...params: SqliteValue[]): Promise<T | undefined>; all<T extends object>(sql: string, ...params: SqliteValue[]): Promise<T[]>;}/** * Minimal portable SQLite database facade. * * All operations are asynchronous so adapters may execute outside the caller's runtime. * * `transaction` passes the callback a transaction handle. All work in the transaction * must use that handle; the handle is invalid after the callback settles. Adapters must * queue unrelated operations and other transactions until the transaction finishes. The * returned promise settles after commit or rollback. Calling the database itself (including * `transaction` or `close`) from inside a callback therefore waits for that transaction and * never settles. * * When the callback rejects, the adapter must roll the transaction back before rejecting * with that same error. If rollback fails, it must reject with a different error (for * example an `AggregateError`) so callers cannot mistake the callback error for a * guaranteed rollback. */export interface SqliteDatabase extends SqliteExecutor { transaction<T>(callback: (transaction: SqliteExecutor) => Promise<T>): Promise<T>; close(): Promise<void>;}transaction hay close) từ bên trong callback của transaction sẽ chờ transaction đó và không bao giờ xong. Rollback lỗi thì phải từ chối bằng một lỗi khác lỗi của callback, để người gọi không nhầm là đã rollback chắc chắn.applySqliteMigrations() chạy mọi migration còn thiếu trong một transaction, ghi version vào một bảng metadata một dòng, và từ chối database đã được migrate bởi bản mới hơn (sqlite/migrations.ts:11–43). Adapter Node xếp mọi thao tác vào một hàng nối tiếp, mở transaction bằng BEGIN IMMEDIATE, cache câu lệnh đã prepare theo văn bản SQL, bật WAL với synchronous = NORMAL, chờ khoá tranh chấp 5 s, và wal_checkpoint(TRUNCATE) khi đóng (sqlite/node.ts:130–201). Adapter Durable Object dùng chung một hàng cho mọi adapter trên cùng storage object và từ chối bigint ngoài khoảng an toàn (sqlite/cloudflare.ts:56–75). Cách Durable dựng lược đồ và commit trên facade này nằm ở chương Durable storage.
telemetry-sqlite
createSqliteTelemetry(db) là một adapter telemetry của pi-telemetry ghi vào chính SQLite của ứng dụng, và cũng là một TelemetryStore đọc lại được. Ghi nhận không chạy SQL: span chỉ được đưa vào hàng đợi khi nó kết thúc, metric được gộp thành count, sum, min, max theo từng khoảng 10 giây cho mỗi chuỗi (tên, loại, đơn vị, thuộc tính) (README.md:117–121).
} finally { settled = true; if (!this.stopped) { if (this.spans.length >= this.capacity) this.droppedSpans++; else this.spans.push([ id, identity.traceId, parent?.id ?? null, name, started, started + performance.now() - clock, JSON.stringify(attributes), JSON.stringify(events), JSON.stringify(links), JSON.stringify(status), ]); } this.db.transaction(async (tx) => { for (const span of spans) await tx.run("INSERT INTO telemetry_spans VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ...span); for (const metric of metrics) await tx.run( `INSERT INTO telemetry_metrics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(interval_ms, series_key) DO UPDATE SET count = count + excluded.count, sum = sum + excluded.sum, min = min(min, excluded.min), max = max(max, excluded.max)`, metric.interval, metric.key, metric.name, metric.kind, metric.unit, metric.attributes, metric.count, metric.sum, metric.min, metric.max, ); const cutoff = Date.now() - this.retention; await tx.run("DELETE FROM telemetry_spans WHERE ended_ms < ?", cutoff); await tx.run("DELETE FROM telemetry_metrics WHERE interval_ms < ?", cutoff); }),maxQueuedSpans, mặc định 10.000); tràn thì tăng droppedSpans. Một lần ghi lỗi dừng ghi nhận vĩnh viễn: flush() và close() từ chối với lỗi gốc, không thử lại (telemetry-sqlite/index.ts:319–326). Hạn giữ mặc định bảy ngày.Truy vấn (findTraces, getTrace, findMetrics) chỉ đọc dòng đã commit, kể cả sau khi adapter đóng mà database còn mở; lọc theo thuộc tính dùng json_each và phân biệt true với số 1 (telemetry-sqlite/store.ts:48–64). Hợp đồng TelemetryStore và cách render một trace thuộc pi-telemetry (chương pi-env, telemetry và evals).
Quyết định thiết kế#
Bất biến bằng hợp đồng, không bằng đóng băng
Tracker nhận quyền sở hữu gốc mà không duyệt nó, không đóng băng giá trị đã công bố, và để người tiêu thụ trong cùng tiến trình dùng chung container với nơi cung cấp (README.md:145–152). Hai lựa chọn bị bỏ là Object.freeze (phải duyệt cả cây mỗi revision) và sao chép phòng thủ (phải chép mỗi lần đọc). Với state đổi mỗi 100 ms và view chứa cả transcript, cả hai đều tốn đúng phần tracker được thiết kế để tránh. Cái giá là một đột biến trái phép không bị bắt mà làm hỏng authority trong im lặng; vì vậy quy tắc "clone hoặc serialize ở mọi ranh giới tin cậy có thể đột biến" được nhắc ở cả README lẫn delta README. Ngược lại, giá trị đặt qua draft luôn được chép và kiểm, vì đó là chỗ dữ liệu bên ngoài đi vào.
Prepare tách khỏi adopt
Toàn bộ phần có thể thất bại (sinh phép toán, dựng giá trị) nằm ở prepare(); adopt() là một phép đổi con trỏ không thể lỗi, "so storage failure can discard the candidate without touching authority" (tracker.ts:271–272). Đây là điều kiện để Pi Durable giữ quy tắc "lưu trước, hiển thị sau": giữa prepare và adopt là lần ghi storage. Reload dùng cùng ý ở quy mô lớn hơn: mọi thứ có thể lỗi chạy ở prepare, commit chỉ cài (reload.ts:561–566).
Lô chính xác nhưng không chính tắc, và có trần
Không đòi một dạng chính tắc cho phép toán giúp tracker chọn đường rẻ: gộp nhiều thay đổi thành một splice vùng, một s ở tổ tiên, hay r. Trần 4.096 phép toán của tracker và phép so chi phí của diffRevisions() bảo đảm một lô không bao giờ đắt hơn nhiều so với ảnh chụp. Đổi lại, người tiêu thụ chỉ được dựa vào giá trị kết quả.
Context tường minh thay vì ngữ cảnh ngầm
Context là tham số, không phải AsyncLocalStorage. Nó chạy giống nhau trong Node, trình duyệt và Workers, các lời gọi đồng thời giữ cha độc lập, và không có span "hiện tại" ngầm (README.md:91). Cái giá là phải truyền context qua mọi hàm, đúng như mọi API của Durable làm.
Factory đồng bộ, activation bất đồng bộ
Reload phải chạy lại factory trên object đang sống mà không có hiệu ứng phụ, và cần danh sách khai báo đầy đủ để so với lần trước. Một factory bất đồng bộ không cho được cả hai. Vì vậy factory chỉ khai báo; mở resource, chờ reference và đọc giá trị đầu tiên là việc của activation (spec.md:122–133). Spec ghi rõ những gì đã bị bỏ so với bản guide trước: self.own, holdWhile, factory bất đồng bộ, suy diễn phụ thuộc (spec.md:120).
Root gắn với code version, tối đa hai version
Một root chạy hết trên code nó bắt đầu, và host không bao giờ thay một code version bằng cái khác (spec.md:198). Giữ hai version đủ để mọi root đang chạy xong mà không giữ code cũ vô hạn; root sống quá lâu thì bị huỷ với superseded ở reload kế tiếp. Đây là lý do guide nhắc "Keep roots short. Long-running work is a task, and long waits are state" (guide.md:501).
Cell có tên, không phải một state duy nhất cho mỗi object
Mỗi cell có version và migration riêng, nên đổi một cell không buộc migrate cả object. Spec ghi lại một quyết định đã chốt: "giving each object exactly one state value was rejected. Do not reopen this without a new, concrete problem" (spec.md:288).
Một hàng publication cho cả tiến trình
Mọi thông báo (sự kiện state, shape, bảng registry, listener) đi qua một hàng FIFO đồng bộ, nên mọi người quan sát thấy revision theo thứ tự adopt (mục cell). Spec liệt kê cái giá: một observer chậm làm chậm mọi thông báo sau nó trong tiến trình, kể cả của host khác cùng tiến trình (spec.md:262).
Flow control chỉ cho state, xác nhận đầu cuối
Một relay (Cloudflare tới trình duyệt) có thể che đi một người đọc chậm, nên không thể dựa vào backpressure của transport; xác nhận phải đi từ đầu tới cuối. Kết quả không được chờ sau state, nên link là FIFO trừ việc state đang chờ có thể bị vượt; state đang chờ được gộp thay vì xếp hàng, nên một peer đứng im chỉ tốn bộ nhớ có trần và frame kế tiếp nó nhận là giá trị hiện tại (spec.md:287).
Danh tính đến từ link
Phía chấp nhận đóng dấu principal từ bộ xác thực của nó; principal được chuyển tiếp chỉ được nhận từ link tới cha; trường principal trong payload bị bỏ qua (spec.md:236). Client không bao giờ mang root hay code version (spec.md:282).
Một ngoại lệ có chủ đích: import động
Quy tắc của repo cấm import nội tuyến và import động. nodeLoader là ngoại lệ được chủ dự án duyệt, đánh dấu ngay tại dòng gọi (loader.ts:58) và trong ràng buộc cứng của spec, cùng một TODO(loader): thay bằng một loader cô lập và giải phóng được (spec.md:7).
Cạm bẫy và trường hợp biên#
- Sửa giá trị đọc được là làm hỏng state.
cell.value, giá trị trong listener,tracker.value, payload của một lô phép toán đều có thể chia sẻ container với authority; không có gì bị đóng băng (delta/README.md:186–187). - Draft chết theo callback. Dùng draft hay một handle đọc từ nó sau
prepare(),abort()hay sau khi change khác được adopt thì ném lỗi; callback củachange()phải đồng bộ. undefinedcó luật riêng. Gánundefinedcho thuộc tính object là xoá; gán vào phần tử mảng,push(undefined), hayundefinedlồng trong object được đặt vào thì ném lỗi, không bị bỏ qua nhưJSON.stringify. Mảng luôn dày: ghi quá chỉ số kế tiếp hay xoá một phần tử bằngdeletethì ném lỗi (delta/README.md:115–122).- Callback của
cell.change()trả về giá trị là thay giá trị. Đọc từ code: kết quả khácundefinedđược kiểm theo schema rồi gán vào cell (cell.ts:207–212). Vì vậy(d) => d.push(x)trả về độ dài mới và bị từ chối theo schema của mảng; tutorial luôn viết(draft) => { draft.push(x); }.MutableReplicatedState.change()thì bỏ qua giá trị trả về. - Subscriber của
replicatedStatecó thể nhảy cóc. Quá 100 frame chờ thì chỉ giữ frame mới nhất; đọc giá trị được truyền vào callback, vìstate.valuecó thể đã là revision sau (README.md:181–191). - Kết quả có thể tới trước state. Dưới flow control, một lời gọi trả về không có nghĩa thay đổi state nó gây ra đã tới; chờ đúng state bạn cần (guide.md:744).
- Listener trên state phải hoà giải (reconcile), không đếm. Nó nhận giá trị hiện tại khi bắt đầu, và nhận lại khi reload thay nguồn của nó (mọi derivation đều mới ở mỗi reload) (guide.md:218).
- Biến closure mất ở mỗi reload; state cần giữ phải là cell. Một
selectphụ thuộc giá trị bắt được trong closure sẽ không bị phát hiện là đã đổi; đặt tên listener mới khi đó (spec.md:266). - Link không resume. Hold lại sau khi kết nối lại là object mới; lời gọi đang bay khi link rớt không biết đã chạy hay chưa. Một
closecủa resource chờ lời gọi qua một link đã chết sẽ chờ kết nối lại: hãy giới hạn bằng signal (spec.md:267). - Loader Node rò bộ nhớ. Mỗi reload import bản module mới, bản cũ không bao giờ được giải phóng, và plugin không bị cô lập (spec.md:264).
- Hai kiểu tổ hợp tín hiệu huỷ.
CtxImplcủa host tránhAbortSignal.anyvì lý do giữ bộ nhớ đã nói ở trên, cònwithAbortSignal()của/contextvẫn dùng nó (context/index.ts:111–115). Đây là quan sát từ đọc code; repo không có test hay ghi chú nào cho thấy nó gây rò rỉ trong Durable.
Tài liệu lệch code ở v1.4.0
- Quick start của guide khai báo
Counterbằngremote()mà không cóhost(guide.md:43–52); từ 1.3.0hostlà bắt buộc vàremote()némTypeErrorkhi thiếu (contract.ts:225–226). Bản trong README cóhost: hostNamed("server"). - Bảng khai báo của guide và spec còn ghi
self.get(Type, input, { at? })(guide.md:206; spec.md:104), trong khiGetOptions.atđã bị gỡ ở 1.3.0 (CHANGELOG.md:28);GetOptionschỉ cònprincipalvàstate(types.ts:8–16). - Guide nói "
self.getandself.resourcesonly declare" (guide.md:247);self.resourcesđã bị gỡ ở 1.3.0, API hiện tại làself.resource(name, …)(CHANGELOG.md:24). - Ràng buộc cứng đầu spec nói runtime không phụ thuộc package Pi nào khác (spec.md:6), nhưng
package.jsonphụ thuộc@earendil-works/pi-telemetry(package.json:87–90), được thêm khiContextcó trườngtelemetry(commit 48909d987, 2026-10-18).
Đọc code ở đâu#
Mọi đường dẫn tính từ packages/chord/. Thứ tự dưới đây đi từ tầng nền lên host runtime; nếu chỉ cần phần Durable dùng, dừng sau mục 8.
| # | File | Đọc để hiểu |
|---|---|---|
| 1 | README.md | Năm khái niệm, bảng entry point, telemetry trên Context, tracker và mô hình đăng ký của replicatedState |
| 2 | src/context/index.ts | Context, các hàm dẫn xuất, awaitWithContext |
| 3 | src/delta/README.md | Quyền đột biến, vòng đời, bảng phép toán, giới hạn |
| 4 | src/delta/index.ts | Op, WireOp, an toàn đường dẫn, apply, applyImmutable, encoder/decoder |
| 5 | src/delta/tracker.ts | Bắt đầu ở TrackerImpl và ChangeImpl, rồi setProperty, arrayMutators, emitOperations, emitChangedValue |
| 6 | src/delta/diff.ts, apply-immutable-trusted.ts | Diff cấu trúc, áp phép toán tin cậy |
| 7 | src/state.ts | replicatedState: mutable, gắn nguồn, subscriber có trần |
| 8 | src/output.ts, src/sqlite/ | OutputBuffer, Progress, facade và adapter SQLite |
| 9 | docs/tutorial.md | Danh sách todo dựng dần; mỗi ví dụ chạy trong test/tutorial.test.ts |
| 10 | src/cell.ts, src/publish.ts, src/types.ts | Cell, derivation, hàng publication, Readable, Ctx |
| 11 | src/contract.ts, src/schema.ts | remote/local, placement, coverage, bind, fits, danh tính input |
| 12 | src/object.ts | Self, CtxImpl, RefImpl, ObjectImpl (activation, call, dispose) |
| 13 | src/host.ts | acquire, root, registry, accept/connect/route, relay, close |
| 14 | src/reload.ts, src/cluster.ts, src/loader.ts | Prepare, gate, commit, retire; reload cả cụm; loader Node |
| 15 | src/wire.ts, src/link.ts, src/websocket.ts | Frame, ba transport, nén payload |
| 16 | src/connection.ts, src/remote.ts | Unit, feed, flow control, relay; binding và view phía consumer |
| 17 | src/telemetry-sqlite/, src/manager.ts | Ghi telemetry vào SQLite; quản lý tiến trình host con |
| 18 | docs/guide.md, docs/spec.md | Tham chiếu API; ngữ nghĩa và §12 giới hạn, quyết định (spec nói: lệch với code là bug) |
| 19 | test/delta-tracker/, test/link.test.ts, test/reload.test.ts | Hành vi biên của tracker, link và reload, ở dạng có thể chạy |
Tóm tắt#
- Chord là host runtime có kiểu cho plugin nạp lại nóng, contract, state nhân bản và RPC qua link JSON; bên dưới là các mảnh dùng độc lập: Context, delta,
replicatedState, output, SQLite. - Ở v1.4.0 chỉ Pi Durable và pi-env phụ thuộc Chord, và chỉ dùng tầng nền; không package nào khác trong repo tạo host.
Contextmang huỷ, span telemetry và giá trị có khoá, truyền tường minh; huỷ một lần chờ không huỷ công việc.Ctxcủa host runtime là thứ khác.- Thay đổi là lô bảy phép toán (
r s d a t p m), chính xác nhưng không chính tắc; trên dây, đường dẫn được đánh mã từ lần dùng thứ hai vàrlà điểm khôi phục. - Tracker: draft proxy,
prepare()sinh phép toán và revision mới mà không đổi authority,adopt()là phép đổi con trỏ; đó là móc để Durable ghi storage trước khi hiển thị. - Host runtime: contract là giá trị lúc chạy với luật bind theo version và "phủ"; object tại địa chỉ
(type, input, principal), chỉ chạm tới qua reference gắn muộn; factory đồng bộ, activation bất đồng bộ. - Hot reload: load, prepare không hiệu ứng phụ, gate khi state đổi nghĩa, commit đồng bộ, retire theo luật hai phiên bản; root chạy hết trên code nó bắt đầu.
- Link chở frame JSON đã kiểm schema; principal do phía chấp nhận đóng dấu; state đi thành unit rồi ops theo encoder của từng luồng, có flow control chỉ cho state.
17Pi Durable: mô hình khái niệm#
Chương này dựng mô hình khái niệm của @earendil-works/pi-durable (gọi tắt Pi Durable): harness bền (durable harness) mà Earendil phát hành cùng Pi 1.0. Ta đi từ bài toán, qua các bản ghi và kiểu dữ liệu thật, tới một lần theo dấu (trace) đầy đủ từ lúc gửi một câu hỏi đến lúc nhận câu trả lời. Phần bên dưới mặt nước (Session, transaction, các backend lưu trữ) nằm ở chương Durable: session, commit, document và storage; task, scheduler, generation và tool nằm ở chương Durable: task, scheduler, generation và tool.
Vai trò: vì sao có Pi Durable#
Coding agent của Pi (CLI pi, xem chương AgentSession) chạy trong một terminal, cho một người. Nếu tiến trình chết giữa một lượt, người dùng xem lại chuyện đã xảy ra rồi bảo nó làm tiếp. Bài "Pi Durable" của Earendil (2026-10-01) đặt ra một bài toán khác: một harness chạy được ở bất cứ đâu có runtime JavaScript, được truy cập từ nhiều bề mặt (giao diện terminal, web, bot chat), chứa rất nhiều hội thoại dài, sống sót qua lỗi bên trong lẫn bên ngoài, và cho nhiều người cùng điều khiển một agent.
Bài viết đưa ra vài tình huống cụ thể. Một agent bị cắt ngang giữa lời gọi tool; một tiến trình mới mở lại cùng storage và chạy tiếp. Request tới mô hình bị đứt được gửi lại, phần trả lời dở được giữ và đánh dấu là bị huỷ (aborted). Một tool không an toàn khi chạy lại, như deploy, được báo cho mô hình là bị gián đoạn thay vì chạy lần thứ hai. Một kênh Slack là một hội thoại, một thread mở từ một câu trả lời trong kênh là một fork (tách nhánh), và cả hai chạy song song.
README của package tóm lời hứa đó trong hai câu: hội thoại, lượt mô hình, lời gọi tool và state riêng của ứng dụng đều được commit vào storage trước khi hiển thị bất cứ thứ gì; nếu tiến trình chết giữa lượt, mở lại storage là công việc tiếp tục từ chỗ dừng (README.md:5). Nó dựng trên pi-ai để gọi mô hình và trên Chord để giữ state của document (README.md:7).
Quan hệ với phần còn lại của Pi cần nói rõ, vì tên gọi dễ gây nhầm:
- Không dùng
AgentSession, không dùngpi-agent-core. Phụ thuộc trực tiếp của package là Chord, pi-ai, pi-codemode và pi-telemetry (package.json:152–156). Vòng lặp agent ở đây là các task bền: taskpi.generationtự gọimodels.streamSimple()của pi-ai (generation.ts:484; spec.md:3953–3960). Coding agent cũng không phụ thuộc Pi Durable; chương Bản đồ monorepo vẽ hai chồng package này cạnh nhau. - Không thay thế coding agent. Bài công bố viết "It does not replace the Pi coding agent": Pi Durable là framework để xây ứng dụng có agent, coding agent chỉ là một loại, và bài học rút ra sẽ chảy ngược về coding agent khi chứng minh được giá trị.
- Ai gọi nó. Một ứng dụng host do bạn viết: tiến trình Node, server, Durable Object của Cloudflare, hay trang web. Host mở
Harness.open(storage, options, context), cài extension vào một registry, rồi gửi input và quan sát state đã commit.
Mô hình tư duy#
Toàn bộ thiết kế xoay quanh một quy tắc, ghi ngay đầu đặc tả:
Nghĩa là: mọi thay đổi (một entry mới, một task đổi trạng thái, một document được sửa) đi qua cùng một dòng commit nguyên tử; giao diện, sự kiện và mọi thứ bên ngoài chỉ thấy những gì đã nằm trong storage. Không có kênh hiển thị nào "nhanh hơn storage". Danh sách bất biến ngay sau đó cấm hiệu ứng bên ngoài (gọi mô hình, chạy tiến trình) chạy bên trong transaction, và coi lỗi storage là lỗi chết của Session (spec.md:63–78).
Ứng dụng host (tiến trình Node, server, Durable Object, trình duyệt)
│ submit · configure · reset · compact · abort · viewState · watch · watchEvents
▼
Harness ──────────── Registry: extension (tool, section, hook, wrap, task)
│ chỉ sống trong bộ nhớ tiến trình, không được lưu
├─ Submissions nhận input/write, inbox, wait()
├─ TaskScheduler chạy task: pi.generation, pi.tool, pi.compaction, task riêng
├─ ConversationViews mount view cho UI, dẫn xuất từ các commit
▼
Session một dòng commit nguyên tử; cache document dưới dạng tracker của Chord
▼
Storage Memory · SQLite · JSONL · IndexedDB · SQLite của Durable Object
Trong storage: conversation ── entry (bất biến) task (bản ghi thay thế trọn)
└──── document (pi.agent, pi.live, pi.inbox, pi.usage, pi.provider, của bạn)
submission (input hoặc write đang chờ, đã đặt, đã xong)
Trong code, quan hệ "Harness là một Session" là quan hệ kế thừa thật: HarnessImpl mở rộng SessionImpl, và mọi thành phần bên trong đọc ghi qua this.storage, tức storage đã được bọc lớp chặn lỗi của Session.
/** Session kernel extended with conversation handles and a registry. */class HarnessImpl<Tool extends ToolRegistration> extends SessionImpl implements HarnessType { readonly #options: HarnessOptions<Tool>; readonly #hostSettings: () => HarnessSettings | undefined; readonly #report: (error: unknown, info: ReportInfo, context?: Context) => void; readonly #host: ConversationHost<Tool>; readonly #tasks: TaskScheduler; readonly #submissions: Submissions; readonly #taskGraph: TaskGraphView; constructor(rawStorage: Storage, options: HarnessOptions<Tool>, context: Context) { const report = safeReport(options.onReport, options.telemetry ?? NOOP_TELEMETRY_CONTEXT); const now = safeNow(options.now, report); const hostSettings = currentSettings(options.settings); // Read at each use, as all settings are, but only this field: it is read on every commit. super(rawStorage, now, () => hostSettings()?.documentRetentionMs, options.setTimer); // Every component reads and writes through the Session's failure guard: `rawStorage` serves only `super()`. const storage = this.storage;TaskScheduler, Submissions, TaskGraphView và ConversationViews trên cùng storage đó (harness.ts:228–263).Từ vựng dùng trong phần còn lại của chương (định nghĩa gốc ở README.md:92–104 và spec.md:46–61):
| Thuật ngữ | Nghĩa | Kiểu trong code |
|---|---|---|
| Harness | Một storage đang mở cộng bộ máy chạy agent trên đó | Harness, harness/types.ts:783 |
| Session | Chủ của dòng commit, các bản ghi và document | Session, types.ts:1070 |
| Hội thoại (conversation) | Một phạm vi transcript; có thể fork từ hội thoại khác. Handle không giữ state, so sánh bằng id | ConversationRecord, Conversation |
| Entry | Một bản ghi bất biến của transcript | EntryRecord |
| Document | State JSON có kiểu, đặt cạnh transcript, chỉ đổi qua commit | defineDoc(), DocToken |
| Commit | Một lần ghi nguyên tử: entry, document, task cùng lưu hoặc cùng không | Tx, commit() |
| Task | Máy trạng thái bền, lưu checkpoint ở mỗi bước; luôn có chủ (hội thoại hoặc task khác) | TaskRecord, defineTask() |
| Submission | Thứ host gửi vào hội thoại: input của người dùng hoặc một entry cần ghi; có thể chờ | SubmissionRecord, Submission |
| Lượt (turn) / lần chạy (run) | Một câu trả lời của mô hình và các lời gọi tool của nó / chuỗi lượt từ một input tới câu trả lời cuối. Hội thoại bận (busy) khi có run | pi.live.run |
| Extension / Registry | Gói code có tên (tool, section, hook, wrap, task) / tập extension tiến trình này đã cài | Extension, Registry |
| Agent | Thứ một hội thoại chạy cùng: mô hình, mức suy nghĩ (thinking level), extension, tool, instructions, thư mục làm việc. Lưu theo tên, giải lại mỗi lần dùng | AgentState, Agent |
Conversation, entry và các kind dựng sẵn#
ID: một không gian số duy nhất
Mọi bản ghi (hội thoại, entry, task, submission, document) lấy ID từ cùng một dãy số nguyên do storage cấp. Kiểu Id chỉ là number có gắn "nhãn" (brand) lúc biên dịch để TypeScript không cho truyền nhầm TaskId vào chỗ cần EntryId; trong JSON, JSONL và SQLite chúng là số thường (spec.md:242–246).
declare const idBrand: unique symbol;/** Erased nominal number identifying one durable record kind. */export type Id<Kind extends string, Type = unknown> = number & { readonly [idBrand]: { readonly kind: Kind; readonly type: Type; };};export type ConversationId = Id<"conversation">;export type EntryId = Id<"entry">;export type TaskId<Result = unknown> = Id<"task", Result>;export type SubmissionId = Id<"submission">;export type DocumentId = Id<"document">;declare const seqBrand: unique symbol;/** Strictly increasing sequence assigned to one atomic storage commit; gaps are permitted. */export type Seq = number & { readonly [seqBrand]: "sequence" };/** The root conversation always uses this reserved ID. */export const ROOT_CONVERSATION_ID = 1 as ConversationId;Seq là số thứ tự của một commit trong storage, tăng nghiêm ngặt và được phép có khoảng trống. Nó mang nhãn riêng để một ID thực thể không bao giờ bị dùng làm điểm commit của document.Vì dùng chung một dãy và storage hứa cấp số lớn hơn mọi số đã cấp trước đó (types.ts:1185–1189), thứ tự ID cũng là thứ tự tạo: quét entry theo ID là quét theo thời gian. Hội thoại gốc luôn có ID 1, nên mọi backend bắt đầu cấp từ 2; bộ kiểm thử tuân thủ có hẳn một ca cho việc này (storage-conformance.ts:98).
ConversationRecord: cha để thừa kế, chủ để quy trách nhiệm
/** Ownership selected explicitly whenever a conversation is created. */export type ConversationOwnership = { readonly kind: "ownerless" } | { readonly kind: "task"; readonly taskId: TaskId };/** Immutable identity, history ancestry, and task ownership of a transcript scope. */export type ConversationRecord = { readonly id: ConversationId; /** Fork source and inclusive parent entry through which history is inherited. */ readonly parent?: { readonly conversationId: ConversationId; readonly at: EntryId; }; /** Creator edge used for attribution, subtree abort, and subtree idle waits. */ readonly owner?: { readonly conversationId: ConversationId; readonly taskId: TaskId; };};Hai trường tuỳ chọn có hai vai khác nhau (spec.md:247–255). parent nói hội thoại này là fork của hội thoại nào, tại entry nào: nó thấy các entry của cha tới parent.at. owner nói task nào đã tạo ra nó, ví dụ một subagent do tool subagent tạo; cạnh này dùng để huỷ cả cây và chờ cả cây rảnh, không phải để phân quyền. Khi tạo, người gọi luôn chọn quyền sở hữu tường minh (ownerless hoặc task); Session tự suy ra hội thoại của task chủ. Bản ghi bất biến: một hội thoại không bao giờ đổi cha hay đổi chủ.
EntryRecord: thứ mô hình thấy và thứ ứng dụng giữ
/** Immutable transcript event with separate model-facing and application-facing payloads. */export type EntryRecord = { readonly id: EntryId; readonly conversationId: ConversationId; /** Application-defined entry discriminator. */ readonly kind: string; /** Messages contributed to model context; absent for display or bookkeeping entries. */ readonly model?: readonly Message[]; /** JSON payload consumed by views, extensions, or bookkeeping logic. */ readonly data?: JsonValue; /** First entry in the active context selected by this entry. */ readonly head?: EntryId; /** Context-only overrides of earlier visible entries. */ readonly edits?: readonly ContextEdit[]; /** Task that appended this entry, when it was produced by durable work. */ readonly byTaskId?: TaskId;};/** Entry content supplied before the Session assigns identity and task attribution. */export type EntryDraft = Omit<EntryRecord, "id" | "conversationId" | "byTaskId" | "head"> & { /** `"self"` starts active context at the newly assigned entry ID. */ readonly head?: EntryId | "self";};ContextEdit ngay phía trên (types.ts:411–425) là một chỉnh sửa chỉ áp vào ngữ cảnh: omit bỏ phần đóng góp của entry đích, replace thay nó bằng các message khác.Một entry tách hai luồng dữ liệu. model là các message (theo kiểu Message của pi-ai) mà entry đóng góp vào request kế tiếp gửi mô hình; data là JSON tuỳ ý cho view, extension hay sổ sách. Một entry không có model chỉ để hiển thị. head đánh dấu điểm bắt đầu của ngữ cảnh đang hoạt động: entry mang head là một "head marker", và khi viết nháp, head: "self" nghĩa là ngữ cảnh mới bắt đầu ngay tại entry này. edits sửa đóng góp của các entry cũ hơn mà không đụng vào chúng. byTaskId ghi task nào đã append entry, do Session đóng dấu từ phạm vi của commit.
Entry không bao giờ bị sửa hay xoá. Reset, compaction (nén ngữ cảnh) và chỉnh sửa đều là entry mới. Đây là khác biệt lớn so với cây JSONL của coding agent (chương Session): ở đó nhánh là cấu trúc của file; ở đây "nhánh" là một hội thoại khác có parent, và "quên bớt ngữ cảnh" là một head marker.
Các kind dựng sẵn
/** Define a typed entry kind whose `is()` guard narrows by `EntryRecord.kind`. */export function defineEntry<D extends JsonValue = never>(kind: string): Entry<D> { if (typeof kind !== "string" || kind.length === 0) throw new TypeError("Entry kind must be a non-empty string"); return { kind, is: (entry: EntryRecord | undefined): entry is TypedEntry<D> => entry !== undefined && entry.kind === kind, };}/** User input: `model` is `[UserMessage]`. Written by submissions. */export const UserEntry = defineEntry("pi.user");/** Provider result with any stop reason: `model` is `[AssistantMessage]`. Written by generation. */export const AssistantEntry = defineEntry("pi.assistant");/** Positional prompt and tool change: `model` is `[SystemMessage]` with empty `content`. */export const SystemEntry = defineEntry("pi.system");/** * Tool result: `model` is `[ToolResultMessage]`, whose content ends with the rendered diagnostics block; `data` holds * the structured diagnostics, possibly none. Written by tool tasks, and by generation for calls it did not offer. */export const ToolResultEntry = defineEntry<{ diagnostics: ToolDiagnostic[] }>("pi.tool-result");/** * Start of a new context: always `head: "self"`, with `model` absent for a plain reset or `[UserMessage]` carrying the * handoff text. Written by `Conversation.reset()` and the `handoff` tool control. */export const ResetEntry = defineEntry("pi.reset");/** * Compaction summary: `model` is `[UserMessage]` with the wrapped summary, `head` the first kept entry. Written by * compaction tasks, directly or through a write submission. */export const CompactionEntry = defineEntry<{ reason: CompactionReason }>("pi.compaction");kind và hàm thu hẹp kiểu is(). Entry<D> mang kiểu của data: ToolResultEntry luôn có data.diagnostics, CompactionEntry có data.reason, các kind còn lại không có data (spec.md:3975–3979).| Kind | model | Ai ghi |
|---|---|---|
pi.user | [UserMessage], timestamp lấy từ đồng hồ Harness lúc đặt vào transcript | submission, continuation của hook onYield |
pi.assistant | [AssistantMessage] với mọi stop reason, kể cả lỗi và bị huỷ | generation |
pi.system | [SystemMessage] với content: ""; chỉ chứa section và thay đổi tool | bước chuẩn bị của generation |
pi.tool-result | [ToolResultMessage] | task tool; generation, cho lời gọi tới tool không được mời |
pi.reset | không có, hoặc [UserMessage] chứa ghi chú bàn giao; luôn head: "self" | reset(), control handoff của tool |
pi.compaction | [UserMessage] chứa bản tóm tắt; head trỏ vào entry đầu tiên được giữ | task compaction |
Bảng trên tóm spec.md:3981–3988. Mọi câu trả lời của mô hình, kể cả lần thử lỗi, đều thành một entry pi.assistant; vì vậy không cần kind riêng cho "usage" hay "thông báo lỗi". Ứng dụng tự định nghĩa kind của mình bằng defineEntry<D>("app.note"); ví dụ 08-harness-conversations.ts:19 tạo kind message có data.from. Tiền tố pi. thuộc về các kind dựng sẵn theo quy ước; đặc tả nói rõ không có gì cưỡng chế điều đó (spec.md:5565–5568), trừ một chỗ: input gửi kèm entry.kind riêng bị từ chối nếu kind bắt đầu bằng pi. (submissions.ts:182–189).
Từ transcript tới ngữ cảnh của mô hình
"Mô hình thấy các entry từ lần reset mới nhất trở đi" (README.md:96). Chính xác hơn, việc dẫn xuất ngữ cảnh lấy head marker mới nhất H, quét các entry nhìn thấy được từ H.head tới cuối, áp chỉnh sửa (chỉnh sửa mới nhất của mỗi đích thắng), rồi chuẩn hoá cho giao thức của provider:
/** Derive the context view from the entries scanned within `bounds`. */function deriveRange(bounds: ContextBounds, entries: readonly EntryRecord[]): ContextRange { const edits = new Map<EntryId, ContextEdit>(); // Edits of every entry in the range count, including older head markers that `selectActive()` drops. for (const entry of entries) for (const edit of entry.edits ?? []) edits.set(edit.target, edit); const active = selectActive(bounds.head, entries); const contributions = active.map((entry) => contribute(entry, edits.get(entry.id))); const { settled, open } = settle([], contributions.flat()); const baseline = findBaseline(bounds.head, active); const messages = leadWithSystem([...settled, ...orderToolResults(open)], baseline); const view = { head: bounds.head, entries: active, contributions, messages }; return { bounds, entries, view, edited: new Set(edits.keys()), settled, open, baseline };}contribute() loại các message assistant có stop reason aborted, error hoặc deferred (context.ts:10); orderToolResults() đặt kết quả tool ngay sau lời gọi của nó và tự tổng hợp kết quả lỗi cho lời gọi không có kết quả (context.ts:299–321); leadWithSystem() đưa system message nền (baseline) lên đầu (context.ts:212–220).Mười quy tắc đầy đủ nằm ở spec.md:267–302. Hai hệ quả đáng nhớ: một fork cắt ngay sau một lời gọi tool vẫn gửi được request hợp lệ, vì kết quả thiếu được tổng hợp; và view cho UI giữ entry thô, không phải message đã chuẩn hoá (spec.md:304–306). Ví dụ 09-context.ts dựng đủ các trường hợp: message bị huỷ, kết quả về lệch thứ tự, chỉnh sửa, fork ở lời gọi tool, và một head "self".
Document: trạng thái JSON có kiểu#
Bài toán: ứng dụng có state riêng, như danh sách việc cần làm của agent, lựa chọn mô hình của một hội thoại, hay hàng đợi tin nhắn chưa xử lý. State này phải sống sót qua crash cùng lúc với transcript, và khi fork hội thoại thì nhánh mới phải nhận state "đúng lúc" với entry nó tách ra. Nếu state nằm ở một kho riêng, sẽ có lúc transcript đã ghi mà state chưa ghi. Pi Durable giải bằng document: một object JSON có kiểu, gắn vào session, một hội thoại hoặc một task, và chỉ thay đổi bên trong commit, cùng commit với entry.
/** Conversation document that retains only its current state. */export type LatestConversationSemantics = { readonly scope: "conversation"; readonly history: "latest"; readonly fork: "current" | "initial";};/** Conversation document whose history remains addressable for as-of reads. */export type RewindableConversationSemantics = { readonly scope: "conversation"; readonly history: "rewindable"; readonly fork: "asOf" | "current" | "initial";};/** Ownership and lifetime of a document; only conversation documents declare history and fork behavior. */export type DocumentSemantics = | { readonly scope: "session"; readonly history?: never; readonly fork?: never } | LatestConversationSemantics | RewindableConversationSemantics | { readonly scope: "task"; readonly history?: never; readonly fork?: never };/** Stored replay state supplied to a document's checkpoint predicate. */export type CheckpointInfo = { /** Deltas already stored after the newest base, excluding the change being evaluated. */ readonly deltasSinceBase: number;};/** Definition fields shared by singleton documents and document families. */export type CommonDocDefinition<T extends JsonObject> = { /** Stable persisted kind; part of the public protocol. */ readonly kind: string; /** Positive integer version of the stored value shape. */ readonly version: number; initial(): T; migrate?(value: JsonObject, fromVersion: number): T; /** Required policy: return true to store this ordinary change as a complete base instead of a delta. */ checkpointWhen(value: Readonly<T>, ops: readonly Op[], info: CheckpointInfo): boolean;};Các trường của một định nghĩa:
kind,versionkindlà tên được lưu, thuộc giao thức công khai, không đổi được bằng migration;versionlà số nguyên dương của hình dạng giá trị. Giá trị lưu ở version cũ hơn được chuyển quamigrate(value, fromVersion)khi truy cập (spec.md:1596–1601).scopesession(một bản cho cả session),conversation(một bản cho mỗi hội thoại) hoặctask(sống và tự nghỉ hưu cùng task).history,fork(chỉ document hội thoại)latestchỉ giữ giá trị hiện tại;rewindablegiữ lịch sử để đọc "giá trị lúc entry X" bằngsnapshotAsOf().forknói nhánh mới bắt đầu bằng gì:initial(giá trị khởi tạo),current(giá trị hiện tại của cha khi fork) hoặcasOf(giá trị của cha tại entry fork, nên đòirewindable). Ràng buộc này nằm ở kiểu:LatestConversationSemanticskhông chofork: "asOf".initial(),checkpointWhen()initial()chạy khi document chưa có và được tạo lần đầu.checkpointWhen(value, ops, info)quyết định một thay đổi được lưu như một base (giá trị đầy đủ) hay một delta (các phép toán Chord), để chặn độ dài chuỗi phát lại (replay). Nó bắt buộc, không có mặc định (spec.md:1222–1227).
/** Define a Session-scoped singleton document. */export function defineDoc<T extends JsonObject>( definition: CommonDocDefinition<T> & { readonly scope: "session" },): SessionDocToken<T>;/** Define a latest-only conversation singleton document. */export function defineDoc<T extends JsonObject>( definition: CommonDocDefinition<T> & LatestConversationSemantics,): ConversationDocToken<T>;/** Define a rewindable conversation singleton document. */export function defineDoc<T extends JsonObject>( definition: CommonDocDefinition<T> & RewindableConversationSemantics,): RewindableConversationDocToken<T>;/** Define a task-scoped singleton document. */export function defineDoc<T extends JsonObject>( definition: CommonDocDefinition<T> & { readonly scope: "task" },): TaskDocToken<T>;export function defineDoc<T extends JsonObject>(definition: DocDefinition<T>): DocToken<T, DocDefinition<T>> { validateDefinition(definition); return { definition };}function validateDefinition(definition: AnyDocDefinition): void { if (!Number.isSafeInteger(definition.version) || definition.version < 1) { throw new TypeError(`Document ${definition.kind} version must be a positive integer`); }}defineDoc() chỉ kiểm tra version rồi trả về { definition }. Token không được đăng ký ở đâu: nó được truyền tường minh vào mọi lần truy cập (tx.doc(token, id), snapshot(token, id)), và Session không quét document lúc mở.defineDocFamily() làm tương tự cho một họ document cùng phạm vi, phân biệt bằng một khoá chuỗi, với initial(seed). Task tool dùng một họ như vậy, pi.tool.nested-call, để lưu kết quả của từng lời gọi lồng (tool.ts:106–114). Cách ghi, đọc lịch sử, base, delta và fork bên trong được mổ xẻ ở chương storage.
Năm document dựng sẵn
Mọi hội thoại do Harness tạo đều có năm document, tạo ngay trong commit tạo hội thoại bởi hook dựng sẵn:
/** * The built-in creation hook, in every commit that creates or forks a conversation: empty `pi.live`, `pi.inbox`, and * `pi.usage`, a fresh `pi.provider`, the conversation's `pi.agent` (see `createAgent()`), then * `HarnessOptions.conversationCreated`. */protected override async conversationCreated(tx: Transaction, record: ConversationRecord): Promise<void> { await tx.doc(LiveDoc, record.id); await tx.doc(InboxDoc, record.id); await tx.doc(UsageDoc, record.id); await tx.doc(ProviderDoc, record.id); await createAgent(tx, record); await this.#options.conversationCreated?.(tx, record);}tx.createConversation() thô do một tool gọi (spec.md:768–783). Một Session trần (createSession()) không tạo document nào.| Kind | Lịch sử / fork | Chứa gì | Lưu base khi | Định nghĩa |
|---|---|---|---|---|
pi.agent | rewindable / asOf | Lựa chọn của hội thoại, lưu theo tên: mô hình, mức suy nghĩ, extension, tool, instructions, cwd | mọi thay đổi | agent.ts:42–51 |
pi.provider | latest / initial | { sessionId }: UUIDv7 mà pi-ai nhận làm sessionId cho prompt cache và session affinity | mọi thay đổi | provider.ts:11–20 |
pi.live | latest / initial | Quyền điều khiển run, câu trả lời đang stream, các tool đang chạy, compaction đang chạy | không còn gì đang chạy | live.ts:111–127 |
pi.inbox | latest / initial | Các submission đang xếp hàng, theo thứ tự ID | hàng đợi rỗng | inbox.ts:21–29 |
pi.usage | latest / initial | Tổng token và chi phí theo provider/model và theo tên tool | mọi thay đổi | usage.ts:14–22 |
Chính sách fork của từng document là quyết định sản phẩm. pi.agent là asOf để một fork chạy với đúng agent mà cha có tại entry fork. Bốn document còn lại là initial: một fork bắt đầu với inbox rỗng (hàng đợi thuộc về hội thoại gốc), không có run, chi phí bằng không (để không đếm hai lần, spec.md:4433–4436) và một sessionId mới (spec.md:866–872). Riêng pi.agent của hội thoại do task sở hữu được sao chép từ hội thoại của task chủ, một lần, lúc tạo:
/** * Built-in part of every Harness commit that creates or forks a conversation, for `pi.agent`: a fork keeps its `asOf` * copy; a new task-owned conversation copies the stored agent of its owner task's conversation; a new ownerless one * starts empty. */export async function createAgent(tx: Tx, conversation: ConversationRecord): Promise<void> { if (conversation.parent !== undefined) return; const agent = await tx.doc(AgentDoc, conversation.id); if (conversation.owner === undefined) return; const owner = await tx.doc(AgentDoc, conversation.owner.conversationId); Object.assign(agent, copyJson(owner) as AgentState);}pi.live là document bận rộn nhất và cũng là định nghĩa của "đang bận":
/** Built-in live conversation state: run control and presentation of the current generation and tool round. */export type LiveState = { /** Run control: the task that settles the run's inputs, and those inputs; present exactly while busy. */ run?: { taskId: TaskId; inputs: SubmissionId[] }; /** Presentation of the current generation attempt. */ generation?: { attempt: number; /** Committed throttled partial of the in-flight response. */ message?: JsonRepresentation<AssistantMessage>; /** Durable backoff before the next attempt. */ retry?: { at: number; error: string }; /** Provider-side deferred response being polled. */ deferred?: { pollAt: number }; }; /** The current tool round in call order, from the tool-calling answer until the generation's `tools` phase ends it. */ tools?: ToolSlot[]; /** * Nested calls of running tool calls, in creation order, so a parent precedes its children and task IDs ascend. A * call's nested slots are removed when the call settles. */ nestedTools?: NestedToolSlot[]; /** Live compaction tasks in task ID order; absent when none. */ compactions?: CompactionStatus[]; /** * The newest compaction failure, so a client can show it after the task ended. A compaction that produces a summary * removes it; one that is aborted or finds nothing to compact leaves it. */ compactionFailure?: CompactionFailure;};run có mặt đúng khi hội thoại bận; taskId là generation chịu trách nhiệm trả lời các inputs, và giá trị này được chuyển từ generation này sang generation kế tiếp suốt một run (spec.md:2528–2537).Chính sách checkpoint của nó cho thấy cách chọn checkpointWhen có chủ đích: lưu base mỗi khi không còn gì đang chạy, tức lúc rảnh, lúc generation giao cho vòng tool, và giữa các tool. Vì một slot chỉ giữ output khi đang chạy, mọi base đều nhỏ, và chuỗi delta không bao giờ dài hơn một generation (live.ts:118–126; spec.md:4092–4101).
Ứng dụng định nghĩa document của mình theo cùng cách. README cho ví dụ app.todos sửa trong một commit rồi đọc lại bằng harness.snapshot() (README.md:728–747); HarnessOptions.conversationCreated cho phép tạo document của ứng dụng trong mọi hội thoại, và extension đọc document của mình qua api hoặc input.read, coi document vắng mặt là giá trị mặc định (11-extension-state.ts:32–56).
Commit: bề mặt giao dịch#
Mọi thay đổi đi qua một callback nhận tx (kiểu Tx). Những gì callback ghi được lưu cùng nhau khi nó trả về, hoặc bị bỏ hết nếu nó ném lỗi (README.md:97). Ví dụ 01-documents.ts:28–32 append một entry và sửa một document trong cùng một commit. Phần ghi của Tx:
/** Create a conversation with explicitly selected ownership. */createConversation(options: { readonly ownership: ConversationOwnership }): Promise<ConversationRecord>;/** Create a history fork at one concrete visible entry with explicitly selected ownership. */forkConversation( parentConversationId: ConversationId, at: EntryId, options: { readonly ownership: ConversationOwnership },): Promise<ConversationRecord>;/** Returned records are Session-owned immutable values and may be shared with commit listeners. */appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord>;/** The token supplies `kind` and types `data`. */appendEntry<D extends JsonValue>( token: Entry<D>, conversationId: ConversationId, value: TypedEntryDraft<NoInfer<D>>,): Promise<TypedEntry<D>>;createTask<I, S extends { phase: string }, R, H extends object>( task: Task<I, S, R, H>, input: I, options: TaskOptions,): Promise<TaskId<R>>;createSubmission, settleSubmission, placeSubmission) và document (doc, retireDoc) nằm ngay dưới (types.ts:953–1005).Có ba cửa vào cùng một cơ chế, khác nhau ở phạm vi mặc định:
harness.commit(tx => ...): commit của Session, không gắn với hội thoại nào;tx.createTask()khi đó phải được choconversationId.conversation.commit(tx => ...): cùng commit nhưng gắn với hội thoại đó, nêntx.createTask()mặc định thuộc hội thoại này (harness.ts:141–143).runtime.commit((tx, current) => next)trong phase của task vàapi.commit()trong tool: commit còn đóng dấubyTaskIdlên entry, và với task thì giá trị trả về là trạng thái kế tiếp của task, ghi trong cùng commit (types.ts:266–278; xem chương task).
Bốn quy tắc cần thuộc trước khi viết commit (chi tiết thực thi ở chương storage):
- Đọc bảng trước, ghi bảng sau. Bảng là hội thoại, entry, task và submission. Sau lần ghi bảng đầu tiên, mọi lần đọc bảng ném
ReadAfterWrite; document vẫn đọc ghi được (spec.md:1728–1734; errors.ts:16–22). - Không làm việc chậm bên trong. Callback giữ dòng commit của cả Session; không được chờ mô hình, tool, mạng, con người hay một commit lồng (spec.md:5521–5523).
- Đọc qua
tx, không qua Session.snapshot(),getTask()và các lần đọc khác có thể xếp hàng sau chính commit đang giữ dòng; gọi chúng bên trong callback có thể treo vĩnh viễn, và chỉ treo khi trượt cache nên lỗi lúc có lúc không (spec.md:5524–5528). - Draft chết khi callback xong.
tx.doc()trả về một draft của Chord; giá trị gán vào được sao chép ngay và phải là JSON nghiêm ngặt; đọc hay ghi draft sau khi callback xong sẽ ném lỗi (spec.md:1519–1535).
Submission và inbox#
Host không append entry trực tiếp vào một hội thoại có thể đang bận. Nó gửi một submission: hoặc input của người dùng (có thể khởi động một run), hoặc một entry cần ghi (write). submit() trả về ngay sau khi submission được ghi bền vào storage (admission), không đợi câu trả lời; đối tượng Submission trả về là thứ để chờ (spec.md:1002–1011).
/** * The entry an input is placed as, instead of `pi.user`: an application kind, and data the transcript shows, such as its * author. Its model message is still the input's user message. */export type InputEntry = { readonly kind: string; readonly data?: JsonValue };/** Host submission: user input that may start a run, or a passive entry write. */export type SubmissionDraft = { readonly requestId?: string;} & ( | { readonly type: "input"; readonly content: UserInput; readonly whenBusy?: "steer" | "followUp" | "reject"; /** Place the input as this entry; default `pi.user` without data. The kind must not start with `pi.`. */ readonly entry?: InputEntry; } | { readonly type: "write"; readonly entry: EntryDraft; readonly content?: never; readonly whenBusy?: never; });export type InputSubmissionDraft = Extract<SubmissionDraft, { readonly type: "input" }>;export type SettledSubmissionRecord = SubmissionRecord & { readonly status: "done" | "unanswered";};/** Awaitable host object for one durably admitted submission. */export interface Submission { readonly id: SubmissionId; status(context: Context): Promise<SubmissionRecord>; wait(context: Context): Promise<SettledSubmissionRecord>; abort(context: Context): Promise<"aborted" | "already_placed" | "settled">;}Mỗi submission có một bản ghi bền với vòng đời riêng. Với input: queued (đã nhận, chưa vào transcript), placed (đã thành entry, thuộc một run đang chạy), rồi done (kèm answer là ID entry câu trả lời) hoặc unanswered (kèm reason). Write không có trạng thái placed: đặt vào transcript là xong.
export type SubmissionRecord = | (SubmissionRecordBase & { readonly type: "input"; } & ( | { /** Admitted but not yet represented in the transcript. */ readonly status: "queued"; readonly entry?: never; readonly answer?: never; readonly reason?: never; readonly detail?: never; } | { /** Added to the transcript and owned by an active run. */ readonly status: "placed"; readonly entry: EntryId; readonly answer?: never; readonly reason?: never; readonly detail?: never; } | { /** Successfully answered user input. */ readonly status: "done"; readonly entry: EntryId; readonly answer: EntryId; readonly reason?: never; readonly detail?: never; } | { /** Terminal input that can no longer receive an answer. */ readonly status: "unanswered"; readonly entry?: EntryId; readonly answer?: never; readonly reason: string; readonly detail?: JsonValue; } ))id, conversationId, requestId, trace ở types.ts:465–473.Admission: một commit quyết định số phận
Conversation.submit() chuyển cho Submissions.submit(), vốn bật scheduler (một lời gọi xin tiến triển thì không bao giờ phải chờ một Harness đang tạm dừng) rồi chạy toàn bộ việc nhận trong một commit:
/** Admit a submission in one commit; see `admitSubmission()`. */async submit(conversationId: ConversationId, draft: SubmissionDraft, context: Context): Promise<Submission> { this.#resume(); const parent = draft.type === "input" ? this.#telemetry : context.telemetry; return createTypedSpanStarter(parent, [DURABLE_TELEMETRY_SCHEMA])( "pi.durable.submit", { "submission.type": draft.type, "conversation.id": conversationId, }, async (span) => { if (draft.type === "input") span.addLink({ context: context.telemetry }); try { const { id, status } = await this.#session.commitWith( (tx) => admitSubmission(tx, conversationId, draft, this.#now(), this.#queueModes(), span), withTelemetry(span, context), ); span.setAttributes({ "submission.id": id, result: status }); return new SubmissionHandle(id, this);pi.durable.submit chỉ mang ID và trạng thái, không mang nội dung (README.md:84).admitSubmission() đọc từ trên xuống như một cây quyết định. Đầu tiên là trùng requestId và write chỉ để hiển thị:
if (draft.type === "input" && draft.entry !== undefined) checkInputEntry(draft.entry);if (draft.requestId !== undefined) { const existing = await tx.submissionByRequest(conversationId, draft.requestId); if (existing !== undefined) { if (existing.type !== draft.type) { throw new Error(`Request ${draft.requestId} already identifies a submission of type ${existing.type}`); } return { id: existing.id, status: existing.status === "unanswered" ? "done" : existing.status }; }}const fields = { conversationId, requestId: draft.requestId, trace: span?.propagation?.inject(span) };if ( draft.type === "write" && isDisplayOnly(draft.entry) && !hasQueuedReset((await tx.doc(InboxDoc, conversationId)).items)) { const entry = await tx.appendEntry(conversationId, draft.entry); const write = { ...fields, type: "write", status: "done", entry: entry.id } as const; return { id: (await tx.createSubmission(write)).id, status: "done" };}kind và data, không có model, head hay edits (inbox.ts:131–137). Nó không đổi gì mô hình thấy, nên được append và chốt done ngay, kể cả giữa một run; ngoại lệ duy nhất là khi có một reset đang xếp hàng.Tiếp theo là trường hợp bận, hoặc rảnh nhưng inbox còn mục chờ:
const live = await tx.doc(LiveDoc, conversationId);const busy = live.run !== undefined;if ( draft.type === "input" && draft.whenBusy === "steer" && live.run !== undefined && span?.propagation !== undefined) { const run = await tx.task(live.run.taskId); if (run?.trace !== undefined) span.addLink({ context: span.propagation.extract(run.trace) });}if (busy && draft.type === "input" && draft.whenBusy === "reject") throw new ConversationBusy(conversationId);// A boundary reads the table, so it is prepared before the first table write; a busy one needs none.const boundary = busy ? undefined : await prepareBoundary(tx, conversationId, queueModes);if (boundary === undefined || boundary.inbox.items.length > 0) { const { id } = await tx.createSubmission({ ...fields, type: draft.type, status: "queued", }); // Hosts may leave optional fields `undefined`; drafts take strict JSON. const value = copyJson(draft.type === "write" ? draft.entry : draft.content, { omitUndefinedProperties: true, }); const items = (boundary?.inbox ?? (await tx.doc(InboxDoc, conversationId))).items; if (draft.type === "write") items.push({ id, mode: "write", entry: value as JsonObject }); else { const mode = draft.whenBusy === "steer" ? "steer" : "followUp"; const content = value as JsonRepresentation<UserInput>; const entry = draft.entry === undefined ? {} : { entry: pickInputEntry(draft.entry) }; items.push({ id, mode, content, ...entry }); } if (boundary === undefined) return { id, status: "queued" }; const { users } = await applyBoundary(tx, boundary, "final", now); if (users.length > 0) await startRun(tx, conversationId, live, users); return { id, status: draft.type === "write" ? "done" : users.at(-1) === id ? "placed" : "queued" };}pi.live.run có mặt. whenBusy: "reject" ném ConversationBusy và không ghi gì (errors.ts:48–57). Khi rảnh mà inbox còn mục (ví dụ sau một run thất bại), mục mới xếp sau chúng và một boundary final chạy ngay trong commit này.Cuối cùng là trường hợp phổ biến nhất, hội thoại rảnh và inbox rỗng:
if (draft.type === "write") { if (isStale(boundary, draft.entry)) { const stale = { status: "unanswered", reason: "stale" } as const; return { id: (await tx.createSubmission({ ...fields, type: "write", ...stale })).id, status: "done", }; } const entry = await tx.appendEntry(conversationId, draft.entry); const write = { ...fields, type: "write", status: "done", entry: entry.id } as const; return { id: (await tx.createSubmission(write)).id, status: "done" }; } const entry = await appendInput(tx, conversationId, draft.content, draft.entry, now); const input = { ...fields, type: "input", status: "placed", entry: entry.id } as const; const { id } = await tx.createSubmission(input); await startRun(tx, conversationId, live, [id]); return { id, status: "placed" };}head trỏ về trước phạm vi đang hoạt động là stale: đặt nó sẽ làm sống lại lịch sử mà head mới hơn đã cắt (inbox.ts:144–147). Với input, appendInput() ghi entry pi.user (hoặc kind riêng của input) và startRun() tạo task generation đầu tiên (generation.ts:752–760).Bảng chuyển trạng thái đầy đủ ở spec.md:2539–2552. Tóm tắt các nhánh trên:
| Tình huống | Bản ghi submission | Ghi kèm trong cùng commit |
|---|---|---|
requestId đã có trong hội thoại | không ghi gì, trả về submission cũ | không có |
| Write chỉ để hiển thị, không có reset đang chờ | done, kèm entry | entry |
| Input, rảnh, inbox rỗng | placed, kèm entry | entry pi.user, task pi.generation, pi.live.run |
| Write khác, rảnh, inbox rỗng | done, hoặc unanswered lý do stale | entry (nếu không stale) |
| Bận | queued | mục mới trong pi.inbox |
| Rảnh nhưng inbox còn mục | queued, rồi có thể thành placed hoặc done ngay | mục mới xếp cuối pi.inbox, rồi một boundary final trong cùng commit đặt các mục được chọn và có thể mở run |
Bận và whenBusy: "reject" | không có bản ghi | ném ConversationBusy |
Steer, follow-up và boundary
Mục trong inbox được đặt vào transcript ở các boundary (ranh giới giữa các lượt). Boundary postTools chạy khi một vòng tool kết thúc mà run còn tiếp; boundary final chạy khi run trả lời xong. Write luôn được đặt; tin nhắn steer (chen vào lượt đang chạy) được đặt ở cả hai; follow-up (xếp hàng sau) chỉ ở final, và khởi động run kế tiếp:
const { conversationId, inbox } = boundary;const items = inbox.items;const reset = hasQueuedReset(items);const final = at === "final" || reset;const pick = (mode: "steer" | "followUp", queueMode: QueueMode): number[] => { const indexes = items.flatMap((item, index) => (item.mode === mode ? [index] : [])); return queueMode === "all" ? indexes : indexes.slice(0, 1);};const writes = items.flatMap((item, index) => (item.mode === "write" ? [index] : []));const users = [...pick("steer", boundary.steeringMode), ...(final ? pick("followUp", boundary.followUpMode) : [])];users.sort((a, b) => a - b);steeringMode và followUpMode mặc định là one-at-a-time: mỗi boundary lấy mục đầu tiên của loại đó; all lấy tất cả (agent.ts:78–79). Write được đặt trước, rồi tới input, đều theo thứ tự ID, nên input xếp trước một reset sẽ chạy trong ngữ cảnh mới (spec.md:2584–2592).Một reset đang chờ biến boundary postTools thành final, và các input của run hiện tại kết thúc unanswered với lý do reset, vì ngữ cảnh của chúng đã bị cắt trước khi có câu trả lời (spec.md:2613–2617). Ví dụ 20-inbox.ts chạy đủ steer, follow-up, write và rút lại khi đang bận.
requestId, wait() và abort()
requestId làm cho việc gửi lại idempotent (gọi lại nhiều lần vẫn cho một kết quả) trong phạm vi một hội thoại: lần gửi thứ hai với cùng ID, kể cả sau khi tiến trình khởi động lại, trả về đúng submission đã có (README.md:129–138). Dùng lại một requestId cho loại submission khác thì bị từ chối. Đây là cách host đạt "đúng một lần" (exactly-once): nếu commit có thể đã thành công trước khi tiến trình chết, cứ gửi lại với cùng requestId. Bài "Pi Durable on Cloudflare" áp dụng đúng mẫu này cho lệnh tạo hội thoại: client gửi lại các yêu cầu chưa được xác nhận với cùng request ID.
async wait(id: SubmissionId, context: Context): Promise<SettledSubmissionRecord> { this.#resume(); // Check and register on the line so no settling publication falls between them. const found = await this.#session.readOnLine(async () => { const record = await this.#storage.submission(id, context); if (record === undefined) throw new Error(`Submission ${id} does not exist`); if (isSettled(record)) return { promise: Promise.resolve(record) }; // Close rejects registered waiters synchronously and may begin during the read. if (this.#closed) throw closedError(this.#session); return { promise: this.#waiters.add(id, context) }; }); return found.promise;}/** Withdraw a queued submission and remove its inbox item; placed inputs and settled submissions are reported. */abort(id: SubmissionId, context: Context, conversationId?: ConversationId): Promise<AbortResult | "not_found"> { return this.#session.commitWith(async (tx) => { const record = await tx.submission(id); if (record === undefined || (conversationId !== undefined && record.conversationId !== conversationId)) { return "not_found"; } if (record.status === "queued") { tx.settleSubmission(id, { status: "unanswered", reason: "aborted" }); await removeInboxItem(tx, record.conversationId, id); return "aborted"; } return record.status === "placed" ? "already_placed" : "settled"; }, context);}#observe(publication: CommitPublication): void { for (const change of publication.changes) { if (change.type !== "submission" || !isSettled(change.value)) continue; this.#waiters.resolve(change.value.id, change.value); }}wait() kiểm tra và đăng ký người chờ trên dòng commit, nên không thể có commit chốt nào lọt vào giữa. Người chờ được giải phóng bởi một bộ nghe commit nội bộ, tức chỉ khi việc chốt đã nằm trong storage. Huỷ context của wait() chỉ huỷ việc chờ; rút lại submission là việc của abort(), và chỉ rút được khi còn queued.Các lý do unanswered mà code v1.4.0 thực sự ghi:
| Lý do | Khi nào | Nơi ghi |
|---|---|---|
aborted | Rút lại khi còn xếp hàng; Conversation.abort(); task của run bị huỷ | submissions.ts:137, inbox.ts:160, generation.ts:312 |
stale | Write có head trỏ về trước phạm vi đang hoạt động | inbox.ts:93, submissions.ts:268 |
reset | Một reset cắt ngữ cảnh giữa run | generation.ts:726 |
model_error, no_model | Mô hình trả lỗi không thử lại được; hội thoại không có mô hình | generation.ts:584, generation.ts:396 |
run_failed | Run trước thất bại và settings.afterFailedRun là withdraw | generation.ts:424 |
faulted, hoặc lý do orphan | Task của run lỗi lập trình, hoặc mất định nghĩa và bị huỷ | live.ts:263–266 |
Mặc định (afterFailedRun: "keep"), một run thất bại để nguyên các mục xếp hàng sau nó cho tới khi có input mới hoặc write xếp hàng chạy boundary; continue chạy run kế tiếp với chúng; withdraw chốt chúng unanswered (README.md:220; generation.ts:415–428).
Extension và Registry#
Một mục tiêu của Pi Durable là đổi được code mà không dừng agent đang chạy. Nhưng state bền lại tham chiếu tới code: hội thoại chọn tool nào, task thuộc định nghĩa nào. Cách giải: tách hẳn hai thứ. Code (tool, section prompt, hook, wrapper, định nghĩa task) nằm trong extension có tên, cài vào một registry do ứng dụng sở hữu, chỉ sống trong bộ nhớ tiến trình. Storage chỉ lưu tên. Mỗi lần dùng, tên được giải (resolve) lại với registry hiện tại (spec.md:2644–2649).
/** Built by `hook()`; matches tasks by name. */export type HookRegistration = { readonly task: string; readonly handlers: object };/** Built by `wrapTool()` and `wrapSection()`; targets a tool name or a section key. Wrappers are pure. */export type Wrap<Tool extends ToolRegistration = ToolRegistration> = | { readonly tool: string; wrap(tool: Tool): Tool } | { readonly section: string; wrap(section: PromptSection<Tool>): PromptSection<Tool> };/** Named bundle of code; installed in a registry and selected by conversations by name. */export interface Extension<Tool extends ToolRegistration = ToolRegistration> { readonly name: string; readonly tools?: readonly Tool[]; readonly sections?: readonly PromptSection<Tool>[]; readonly hooks?: readonly HookRegistration[]; /** Apply where this extension is selected, in order. */ readonly wraps?: readonly Wrap<Tool>[]; /** Resolved by name for every task, whichever conversations select this extension. */ readonly tasks?: readonly AnyTask[];}tools: tool của extension; tool trùng tên của extension đứng sau thay tool của extension đứng trước, ở hội thoại chọn cả hai.sections: các mảnh system prompt (mục System prompt bên dưới).hooks:hook(GenerationTask, {...})hayhook(ToolTask, {...})đăng ký handler theo tên task; chỉ chạy ở hội thoại chọn extension này. Danh sách hook của các task dựng sẵn: harness/types.ts:851–907.wraps:wrapTool(tool, fn)vàwrapSection(key, fn)bọc một tool hay section theo tên, ở nơi extension bọc được chọn, và áp lên tool nào đang thắng.tasks: định nghĩa task; khác với mọi thứ khác, chúng được giải theo tên cho mọi task, bất kể hội thoại nào chọn extension, vì một task đang chờ phải tìm lại được code của nó sau khởi động lại.
/** Identity function that types an extension. */export function defineExtension<Tool extends ToolRegistration = ToolRegistration>( extension: Extension<Tool>,): Extension<Tool> { return extension;}/** Hook handlers for tasks with `task`'s name. */export function hook<K extends AnyTask>(task: K, handlers: Partial<HooksOf<K>>): HookRegistration { return { task: task.definition.name, handlers };}/** Wrap the tool named like `tool` wherever the wrapping extension is selected. */export function wrapTool<Tool extends ToolRegistration>(tool: Tool, wrapper: (tool: Tool) => Tool): Wrap<Tool> { return { tool: tool.name, wrap: wrapper };}defineExtension() trả về chính đối số của nó, hook() chỉ ghi lại tên task. Mọi kiểm tra dồn về registry.install() (spec.md:2759–2763).class RegistryImpl<Tool extends ToolRegistration> implements Registry<Tool> { #current = new RegistryState<Tool>([]); readonly #listeners = new Set<() => void>(); snapshot(): RegistrySnapshot<Tool> { return this.#current; } subscribe(listener: () => void): () => void { this.#listeners.add(listener); return () => this.#listeners.delete(listener); } install(extension: Extension<Tool>): void { validateExtension(extension); const current = this.#current.installed(); const index = current.findIndex((installed) => installed.name === extension.name); const next = index < 0 ? [...current, extension] : current.map((installed, at) => (at === index ? extension : installed)); this.#publish(next); } uninstall(extension: Extension): void { const current = this.#current.installed(); if (!current.some((installed) => installed.name === extension.name)) return; this.#publish(current.filter((installed) => installed.name !== extension.name)); } /** Build and validate the next state, which throws on a task name collision, then publish it synchronously. */ #publish(extensions: readonly Extension<Tool>[]): void { this.#current = new RegistryState(extensions); for (const listener of [...this.#listeners]) listener(); }}new RegistryState(...) ném lỗi nếu hai task trùng tên (registry.ts:18–31) trước khi #current bị gán, nên một lần cài hỏng không công bố gì.validateExtension() đòi tên tool và khoá section không trùng trong một extension, khoá section khớp ^[a-z][a-z0-9_-]*$, và cấm khoá instructions, vốn dành cho instructions của agent (registry.ts:95–109). Ba task dựng sẵn pi.generation, pi.tool và pi.compaction luôn có trong mọi registry và không phải extension (registry.ts:9–10); Harness.open() từ chối registry thiếu chúng, nên hãy luôn tạo bằng createRegistry().
Thay code khi đang chạy (reload) theo một quy tắc đơn giản: việc đã bắt đầu giữ code nó đã lấy. Một lời gọi tool đang chạy kết thúc với cài đặt cũ; mỗi phase của task giải hook và agent một lần từ ảnh chụp (snapshot) registry lúc phase bắt đầu; phase, request hay lời gọi kế tiếp dùng code mới (README.md:464–472; spec.md:3799–3813). Sau khởi động lại, host cài lại cùng các extension; task đang chờ của một extension chạy tiếp khi extension đó được cài. Ví dụ 10-registry-reload.ts minh hoạ thay tại chỗ, wrapper và gỡ.
Agent của từng conversation#
Mỗi hội thoại có thể chạy với cấu hình riêng: subagent dùng mô hình rẻ hơn và không có tool subagent; một chế độ lập kế hoạch chỉ cho đọc; một hội thoại review có instructions riêng. Cấu hình này phải sống qua khởi động lại và theo đúng fork. Pi Durable lưu nó trong document pi.agent, dưới dạng tên:
/** Stored choices of one conversation; names, not objects. Unset fields follow the host. */export type AgentState = { model?: ModelRef; thinkingLevel?: ModelThinkingLevel; /** An array selects exactly these extensions, in order. An object edits the host default selection. */ extensions?: string[] | { add?: string[]; remove?: string[] }; /** Filters the selected extensions' tools: the enabled tools. An array enables exactly these, in order. */ tools?: string[] | { remove: string[] }; /** * Filters the enabled tools the model may call (`callers` includes `model`): the tools offered to it. Never widens: * other tools can still call the ones it leaves out. * - Unset: every such tool except deferred ones. * - An array: exactly these, in order, deferred ones too. * - An object: every such tool except deferred ones and those in `remove`, in registry order, followed by the * deferred tools in `add` that are not in `remove`, in `add` order. Names of tools that are not deferred or not * registered have no effect in `add`. */ modelTools?: string[] | { remove?: string[]; add?: string[] }; /** Rendered after every extension section, as the section `instructions`. */ instructions?: string; /** Directory within the environment's file system, passed to `HarnessOptions.env`. */ cwd?: string;};Trường nào không đặt thì theo host (cài đặt chung của Harness). Thay đổi đi qua configure(), nhận các đối tượng extension và tool rồi lưu tên của chúng, nên không gõ nhầm tên được: một trường được truyền thì thay trọn trường đã lưu, null xoá nó về mặc định, undefined giữ nguyên (harness/types.ts:435–451).
/** Apply one change to `pi.agent`: a given field replaces the stored one, `null` clears it, `undefined` changes nothing. */export async function configure(tx: Tx, conversationId: ConversationId, change: AgentChange): Promise<void> { const state = await tx.doc(AgentDoc, conversationId); applyChange(state, change);}function applyChange(state: Draft<AgentState>, change: AgentChange): void { const set = <K extends keyof AgentState>(key: K, value: AgentState[K] | null | undefined) => { if (value === undefined) return; if (value === null) delete state[key]; else state[key] = value as Draft<AgentState>[K]; }; set("model", change.model === undefined || change.model === null ? change.model : { ...change.model }); set("thinkingLevel", change.thinkingLevel);tx, configure() chạy được trong cả commit tạo hội thoại (tham số agent của root(), createConversation(), fork()); Conversation.configure() chỉ là bản chạy trong commit riêng (harness.ts:118–120).Kết quả giải là kiểu Agent:
/** A conversation's agent resolved against a registry snapshot and the settings. */export type Agent<Tool extends ToolRegistration = ToolRegistration> = { readonly model?: ModelRef; readonly thinkingLevel: ModelThinkingLevel; readonly extensions: readonly Extension<Tool>[]; /** The tools a request offers, in order: enabled, callable by the model, and selected by `modelTools`. */ readonly tools: readonly Tool[]; /** The tools nested calls (`executeTool()`) resolve among, in order: enabled and callable by tools. */ readonly callable: readonly Tool[]; /** Enabled deferred tools the model may call and is not offered yet, in registry order: what `tool_search` loads. */ readonly deferred: readonly Tool[]; /** Extension sections, then `instructions` when set. */ readonly sections: readonly PromptSection<Tool>[]; readonly instructions?: string; readonly cwd?: string;};conversation.agent() đọc pi.agent đã commit, lấy ảnh chụp registry hiện tại và cài đặt hiện tại, rồi gọi resolveAgent(); nó không bao giờ ghi:
/** Resolve a conversation's committed `pi.agent` against `snapshot`, or the current one, and the current settings. */async resolveAgent( id: ConversationId, snapshot: RegistrySnapshot<Tool> | undefined, context: Context,): Promise<Agent<Tool>> { const registry = snapshot ?? this.#options.registry.snapshot(); const state = await this.snapshot(AgentDoc, id, context); return resolveAgent(state, registry, resolveSettings(this.#hostSettings()), (error, extension) => this.#report(error, { type: "extension_error", conversationId: id, extension }, context), );}const extensions = selectExtensions(state?.extensions, snapshot, settings);const composed = new Map<string, Tool>();for (const extension of extensions) for (const tool of extension.tools ?? []) composed.set(tool.name, tool);const sections = new Map<string, PromptSection<Tool>>();for (const extension of extensions) { for (const section of extension.sections ?? []) sections.set(section.key, section);}const enabled = filterTools([...composed.values()], state?.tools);const callableBy = (caller: "model" | "tools") => (tool: Tool) => tool.callers === undefined || tool.callers.includes(caller);const modelCallable = enabled.filter(callableBy("model"));const tools = offeredTools(modelCallable, state?.modelTools);const offered = new Set(tools);const deferred = modelCallable.filter((tool) => tool.deferred === true && !offered.has(tool));const callable = enabled.filter(callableBy("tools"));const instructions = state?.instructions;const agentSections = [...sections.values()];if (instructions !== undefined) agentSections.push({ key: INSTRUCTIONS_KEY, render: () => instructions });onReport (agent.ts:336–353).Thuật toán đầy đủ có ở spec.md:2827–2843. Bốn danh sách tool dễ lẫn:
| Danh sách | Gồm | Dùng cho |
|---|---|---|
| enabled (nội bộ) | Tool của các extension được chọn (trùng tên thì cái sau thắng tại chỗ), qua wrapper, rồi qua bộ lọc tools | Giới hạn trên của mọi thứ khác |
tools | Enabled, callers có model, qua bộ lọc modelTools; tool deferred chỉ có mặt khi modelTools nêu tên | Tool được mời trong request tới mô hình |
callable | Enabled, callers có tools | Lời gọi lồng qua api.executeTool(), script của code mode |
deferred | Tool deferred mô hình được gọi nhưng chưa được mời | Thứ tool_search có thể nạp |
modelTools chỉ thu hẹp, không bao giờ mở rộng tools (README.md:347): configure({ modelTools: [codemode] }) cho mô hình chỉ thấy codemode trong khi script của nó vẫn gọi được read và bash. Phần cách tool chạy thuộc chương task.
Ai giải agent, và khi nào, quyết định một thay đổi có hiệu lực lúc nào (spec.md:2881–2893). Bước chuẩn bị của generation giải một lần cho mỗi request, rồi cố định mô hình, tùy chọn stream và tool được mời trong checkpoint của request. Mỗi phase của task giải nhiều nhất một lần. Task tool giải tên tool khi chạy; nếu tool không còn trong tools của agent (bộ lọc, lựa chọn hay registry đã đổi), lời gọi nhận kết quả lỗi tool_unavailable và run tiếp tục. Một cạm bẫy từ chính thiết kế "lưu tên": { add, remove } trong extensions luôn sửa lựa chọn mặc định của host, không sửa một lựa chọn đã chép từ hội thoại chủ (spec.md:837–841). Ví dụ 07-configuration.ts chạy qua mọi trường; 27-plan-mode.ts và 28-reviewer.ts dựng chế độ lập kế hoạch và một hội thoại review từ đó.
System prompt: section và entry hệ thống theo vị trí#
Prompt cache của provider là một tiền tố token khớp chính xác; đổi system prompt hay danh sách tool ở đầu request là trượt cache toàn bộ (bài "Prompt Caching In Agents"). Pi Durable xử lý việc này bằng cách không có state prompt bền nào. Trước mỗi request, các section của extension được chọn render ra prompt mong muốn; transcript chỉ lưu entry pi.system ở đúng vị trí của chúng, mỗi entry là một bản vá (patch) theo kiểu SystemMessage của pi-ai, và chỉ phần thay đổi mới được append (spec.md:3658–3662). Khái niệm system message giữa hội thoại của pi-ai được bàn ở chương Pi 1.0.
/** Input to system prompt section rendering for one request preparation. */export type PromptInput<Tool extends ToolRegistration = ToolRegistration> = { readonly conversationId: ConversationId; /** The request's resolution; `agent.tools` are the tools offered in this request. */ readonly agent: Agent<Tool>; /** Built by `HarnessOptions.env` for this preparation; `undefined` without an environment. */ readonly env: ExecutionEnv | undefined; /** Sections already in effect after replaying the active transcript. */ readonly shown: Readonly<Record<string, string>>; /** Committed document reads. */ readonly read: DocumentReader;};/** One system prompt section; the agent's sections render in order before each request. */export type PromptSection<Tool extends ToolRegistration = ToolRegistration> = { readonly key: string; render(input: PromptInput<Tool>, context: Context): string | undefined | Promise<string | undefined>; /** Default true: wrap the text as `<key>\n...\n</key>`. */ readonly tag?: boolean;};undefined thì section bị bỏ./** * Render the agent's sections in order. `undefined` omits a section; tagged text is wrapped as `<key>\n...\n</key>`. A * section that throws keeps its shown text, if any, and is reported; errors after `context` is aborted propagate. */export async function renderSections<Tool extends ToolRegistration>( sections: readonly PromptSection<Tool>[], input: PromptInput<Tool>, shown: ReadonlyMap<string, string>, report: (error: unknown, section: PromptSection<Tool>) => void, context: Context,): Promise<Map<string, string>> { const desired = new Map<string, string>(); for (const section of sections) { let text: string | undefined; try { text = await section.render(input, context); } catch (error) { if (context.abortSignal?.aborted) throw error; report(error, section); const kept = shown.get(section.key); if (kept !== undefined) desired.set(section.key, kept); continue; } if (text === undefined) continue; desired.set(section.key, section.tag === false ? text : `<${section.key}>\n${text}\n</${section.key}>`); } return desired;}instructions (agent.ts:268–270).Rồi planSystemEntries() so prompt mong muốn với những gì phát lại (replay) từ các system message đang có trong ngữ cảnh:
/** * Plan the `pi.system` entries that make the replayed sections and tools of `view` equal `desired` and `tools` in values * and order. * * - A head marker with no later `pi.system` entry in context: one complete baseline that omits every retained earlier * `pi.system` entry, written even when it restates the replayed values. * - Otherwise, when a minimal section patch would leave a different order: remove every shown section, then re-add * every desired section in order. * - Otherwise the minimal patch of changed values and `null` removals, or nothing. * * Tool changes ride on the last planned entry, or on one entry of their own. */export function planSystemEntries( view: ContextView, desired: ReadonlyMap<string, string>, tools: readonly Tool[], timestamp: number,): SystemDraft[] { const head = view.head; if (head !== undefined && !view.entries.some((entry) => SystemEntry.is(entry) && entry.id > head.id)) { const edits: ContextEdit[] = view.entries .filter((entry) => SystemEntry.is(entry)) .map((entry) => ({ target: entry.id, action: "omit" })); const baseline = systemEntry( Object.fromEntries(desired), { toolsRemoved: [], toolsAdded: tools.map(toToolDeclaration) }, timestamp, ); return [edits.length === 0 ? baseline : { ...baseline, edits }]; } const sections = planSections(replaySections(view.messages), desired); const changes = planTools(getCurrentTools(view.messages), tools); if (changes.toolsRemoved.length === 0 && changes.toolsAdded.length === 0) { return sections.map((patch) => systemEntry(patch, undefined, timestamp)); } if (sections.length === 0) return [systemEntry(undefined, changes, timestamp)]; return sections.map((patch, index) => systemEntry(patch, index === sections.length - 1 ? changes : undefined, timestamp), );}content: "": mọi phần của prompt, kể cả lời mở đầu, là một section có tên (prompt.ts:144–158; spec.md:140–141).Ba nhánh, theo thứ tự ưu tiên:
- Sau một head marker mà chưa có
pi.systemnào sau nó (vừa reset hay compaction): ghi một baseline đầy đủ mọi section và mọi tool, kèm chỉnh sửaomitcho mọipi.systemcũ còn sót trong phạm vi. Lý do: mộtSystemMessageluôn là bản vá, không thể tự xoá các bản vá trước nó (spec.md:3755–3766). - Thứ tự section phải đổi: hai entry trong một commit, entry đầu xoá mọi section bằng
null, entry sau thêm lại theo thứ tự mong muốn; nếu chỉ khẳng định lại giá trị thì phát lại sẽ không ra đúng thứ tự (spec.md:3748–3753). - Còn lại: bản vá tối thiểu gồm giá trị đổi và
nullcho section bị bỏ; không đổi gì thì không có entry nào.
Thay đổi tool đi kèm entry cuối cùng (hoặc một entry riêng): khai báo được so bằng declarationsEqual() của pi-ai và ghi bằng toToolDeclaration(), nên metadata riêng của ứng dụng gắn trên tool không bao giờ lọt vào transcript (spec.md:3771–3778). Một tool thôi resolve được sẽ xuất hiện trong toolsRemoved của delta kế tiếp, và quay lại khi nó resolve được lần nữa.
Vì input của run được commit trước khi generation render prompt, entry pi.system đầu tiên nằm sau entry pi.user đầu tiên trong transcript. Bước dẫn xuất ngữ cảnh đưa baseline đó lên đầu request, vì provider chỉ coi system message đứng đầu là prompt và bộ tool ban đầu (spec.md:284–302); hai bản sửa lỗi ở 1.1.0 và 1.3.0 là về đúng chuyện này (CHANGELOG.md:123, CHANGELOG.md:56). Ví dụ 15-system-prompt.ts in ra các entry pi.system khi section, instructions và wrapper thay đổi.
Harness.open, HarnessOptions và Settings#
export const Harness = { /** Open a Harness over storage. The registry may keep changing while the Harness runs. */ async open<Tool extends ToolRegistration>( storage: Storage, options: HarnessOptions<Tool>, context: Context, ): Promise<Harness> { context.abortSignal?.throwIfAborted(); const snapshot = options.registry.snapshot(); const missing = BUILTIN_TASKS.filter((task) => snapshot.task(task.definition.name) === undefined); if (missing.length > 0) { const names = missing.map((task) => task.definition.name).join(", "); throw new Error(`Registry lacks built-in tasks ${names}; create it with createRegistry()`); } const harness = new HarnessImpl(storage, options, context); try { await harness.openTasks(context); } catch (error) { // The caller's context may be what failed open: close without it, and rethrow the open error. // A failing Storage close fails the Session, which reports it. await harness.close(withoutAbortSignal(context)).catch(() => {}); throw error; } return harness; },};openTasks() đổi các task còn ở running (tiến trình trước chết giữa chừng) về pending và đếm số lần khởi động lại; nó không chạy task nào. Scheduler chỉ bật khi host gọi resume() hoặc một lời gọi xin tiến triển như submit(), wait(), waitForTask(); bên xem chỉ đọc không bao giờ bật nó (spec.md:744–755). Nhờ vậy host có thể cài extension, gieo dữ liệu, rồi mới cho chạy.Hội thoại gốc được tạo lười. Lần gọi root() đầu tiên tạo hội thoại ID 1, chạy hook tạo hội thoại, áp agent rồi chạy init, tất cả trong một commit; các lần sau, kể cả sau khi mở lại, chỉ trả về nó và bỏ qua cả hai tham số (spec.md:736–741):
async #create(target: CreateTarget, options: CreateOptions, context: Context): Promise<Conversation> { this.assertUsable(); const id = await this.commitWith(async (tx) => { if (target.kind === "root" && (await tx.conversation(ROOT_CONVERSATION_ID)) !== undefined) { return ROOT_CONVERSATION_ID; } const record = target.kind === "root" ? await tx.createRootConversation() : target.kind === "fork" ? await tx.forkConversation(target.parentId, target.at, { ownership: target.ownership }) : await tx.createConversation({ ownership: target.ownership }); if (options.agent !== undefined) await configure(tx, record.id, options.agent); if (options.init !== undefined) await options.init(tx, record.id); return record.id; }, context); return new ConversationImpl(id, this.#host);}ConversationImpl, chỉ giữ id và tham chiếu tới Harness (harness.ts:105–112). Handle thuộc về Harness đang mở; sau khi mở lại phải lấy lại (spec.md:1059–1060).HarnessOptions
export type HarnessOptions<Tool extends ToolRegistration = ToolRegistration> = { /** Host-owned adapter for content-free admission/phase telemetry; propagation and metrics are optional. */ readonly telemetry?: TelemetryContext; /** * pi-ai model access used by generation, compaction, hooks and tools: a `Models`, or a function returning the current * one, called at each use, so a host can replace its model configuration without reopening the Harness. */ readonly models: Models | (() => Models); /** * How model requests identify the app to providers, merged field by field over `DEFAULT_HARNESS_IDENTITY` * (`{ attribution: PI_ATTRIBUTION }`, provider attribution to pi). It applies to every request through the Harness's * models, including hooks and tools, and overrides the identity the `Models` was created with; a request's own * `identity` overrides it. Pass `{ attribution: null }` to send no attribution headers. */ readonly identity?: ClientIdentity; readonly registry: RegistryReader<Tool>; /** Run settings, or a function returning the current ones; read at each use either way. */ readonly settings?: HarnessSettings | (() => HarnessSettings | undefined); /** Builds a conversation's environment at each use. Never called on the Session line; may be async. */ readonly env?: (target: EnvTarget, context: Context) => ExecutionEnv | undefined | Promise<ExecutionEnv | undefined>; /** * Runs in every commit that creates or forks a conversation, raw `tx.createConversation()` included, after the * built-in `pi.*` documents and before the conveniences apply `agent` and run `init`. A fork already has its copies. * Table reads throw `ReadAfterWrite`, as in `init`; a throw fails the creating commit. */ readonly conversationCreated?: (tx: Tx, conversation: ConversationRecord) => void | Promise<void>;models,identityModelscủa pi-ai (chương pi-ai), hoặc một hàm trả về nó, được gọi mỗi lần dùng.identitylà mộtClientIdentityđược trộn lênDEFAULT_HARNESS_IDENTITY(ghi nhận nguồn là pi với OpenRouter, NVIDIA NIM, OpenCode) và áp cho mọi request đi qua Harness, kể cả từ hook và tool;{ attribution: null }để không gửi gì (README.md:80; harness.ts:187–199).telemetry- Một
TelemetryContextcủapi-telemetrydo host sở hữu; bỏ trống thì không làm gì. SchemaDURABLE_TELEMETRY_SCHEMAchỉ có ID và metadata điều khiển, không bao giờ có message, đối số hay kết quả (README.md:82–90). Chi tiết ở chương pi-env và telemetry. env- Hàm dựng môi trường thực thi (
ExecutionEnv) cho một hội thoại từ ID,cwdtrongpi.agentvà một bộ đọc document đã commit; không bao giờ được gọi trên dòng commit, được gọi lại mỗi lần dùng (harness.ts:279–285; spec.md:918–935). Môi trường Node và pi-env ở chương pi-env; cách tool dùng nó ở chương task. conversationCreated- Chạy trong mọi commit tạo hoặc fork hội thoại, sau năm document dựng sẵn, để host tạo document của mình ở mọi hội thoại; đọc bảng ở đây ném
ReadAfterWritevì việc tạo hội thoại đã là một lần ghi bảng. now,setTimer,onWake,onReport- Đồng hồ và bộ hẹn giờ của host (chạy được trên đồng hồ giả);
onWakethay bộ hẹn giờ cho các task đang ngủ bằng báo thức của host, như alarm của Durable Object (mục Cloudflare ở chương storage);onReportnhận các lỗi không làm hỏng thao tác gọi, kèmReportInfocho biết chuyện gì xảy ra (types.ts:148–188).
Settings: đọc trực tiếp, không bao giờ lưu
Chính sách chạy dùng chung cho mọi hội thoại (lựa chọn extension mặc định, tùy chọn stream, thử lại, ngưỡng compaction, nhịp commit tiến độ, cách chạy tool, chế độ hàng đợi, thời gian giữ bộ nhớ, giới hạn khởi động lại) đi qua settings. Nó được đọc lại ở mỗi lần dùng và không bao giờ được chép hay lưu, nên một object có getter hoặc một hàm làm cho cài đặt "sống" theo file cài đặt của người dùng mà không cần commit nào (README.md:403–425). Settings phải đồng bộ, vì chế độ hàng đợi được đọc ngay trên dòng commit.
/** Resolve the host settings: every field over its built-in default, object fields merged. */export function resolveSettings(settings: HarnessSettings | undefined): Settings { const extensions = settings?.extensions; return { ...(extensions === undefined ? {} : { extensions }), stream: { ...settings?.stream }, retry: { ...DEFAULT_RETRY_POLICY, ...settings?.retry }, compaction: { ...DEFAULT_COMPACTION_POLICY, ...settings?.compaction }, // Field by field, so an explicitly undefined interval keeps its default. progress: { partialIntervalMs: settings?.progress?.partialIntervalMs ?? DEFAULT_PROGRESS_POLICY.partialIntervalMs, outputIntervalMs: settings?.progress?.outputIntervalMs ?? DEFAULT_PROGRESS_POLICY.outputIntervalMs, }, toolExecution: settings?.toolExecution ?? "parallel", steeringMode: settings?.steeringMode ?? "one-at-a-time", followUpMode: settings?.followUpMode ?? "one-at-a-time", contextRetentionMs: settings?.contextRetentionMs ?? DEFAULT_CONTEXT_RETENTION_MS, documentRetentionMs: documentRetention(settings?.documentRetentionMs), afterFailedRun: settings?.afterFailedRun ?? "keep", restartLimit: restartLimit(settings?.restartLimit), };}afterFailedRun: "keep"; restartLimit 5 (agent.ts:20–37).Hàm currentSettings() biến cả hai dạng thành một hàm gọi ở mỗi lần dùng (harness.ts:201–204); riêng documentRetentionMs được đọc ở mỗi commit (harness.ts:220–221). Đổi cài đặt không tạo commit nào và không phát sự kiện nào; một UI hiển thị mô hình hay tool đã giải phải gọi lại conversation.agent().
Quan sát: viewState, watch, watchEvents#
Mọi thứ một UI cần đều là state đã commit (README.md:476). Câu trả lời đang stream nằm trong pi.live.generation.message, output của tool đang chạy nằm trong slot của nó, hàng đợi nằm trong pi.inbox. Không có "kênh sự kiện" riêng nào chạy song song với storage. Vì vậy một client vào muộn, hay kết nối lại, chỉ cần lấy state hiện tại rồi nhận các thay đổi sau đó; không có gì phải phát lại. Đổi lại, tiến độ chỉ hiện ra theo nhịp commit: câu trả lời dở và output tool được commit nhiều nhất mỗi 100 ms theo mặc định, nên crash mất tối đa khoảng đó (README.md:502).
Có ba cách quan sát một hội thoại, cùng dựa trên một "mount" (bản dựng view):
/** Structural mount of one conversation's active transcript and built-in documents (spec §9.3). */export type ConversationView = { readonly conversation: ConversationRecord; /** Raw active entries, as `ContextView.entries`: the head marker, then the non-head entries from its head. */ readonly entries: readonly EntryRecord[]; /** Built-in conversation documents keyed by kind; absent documents are absent. */ readonly docs: Readonly<Record<string, JsonObject>>;};documentState() hay watchDoc() riêng (spec.md:4785–4786).viewState()Một state Chord chỉ đọc, có thể huỷ (AttachedReplicatedState<ConversationView>), cập nhật sau mỗi commit chạm tới hội thoại. Hợp cho UI cục bộ:view.subscribe(render).watch()Cùng view đó, giao từng frame một: giá trị bất biến cộng đúng các phép toán Chord của commit đã tạo ra nó, callback bất đồng bộ, không chồng nhau. Hợp để gửi ops cho một client từ xa áp lại (README.md:489–498).watchEvents()Bộ chuyển đổi thử nghiệm dịch các commit thành sự kiện có hình dạng như sự kiện của coding agent (message_start,tool_execution_update, ...), bắt đầu bằng một sự kiệnsnapshot.
Harness giữ nhiều nhất một mount cho mỗi hội thoại. Observer đầu tiên dựng nó trên dòng commit từ transcript và document đã commit; observer cuối cùng rời đi thì nó bị bỏ (view.ts:142–166). Mỗi revision tiếp theo được dẫn xuất từ publication của commit, không đọc lại storage:
const docOps: Op[] = [];const entryOps: Op[] = [];let entries = mount.value.entries;// Entry writes are published in ID order.for (const change of publication.changes) { if (change.type === "entry" && change.value.conversationId === id) { const entry = change.value; const value = entry as unknown as JsonValue; if (entry.head === undefined) { entryOps.push(["p", ["entries"], entries.length, 0, [value]]); entries = [...entries, entry]; continue; } // A head marker keeps the non-head entries from its head, which are always a suffix, and goes in front. const target = entry.head; let kept = entries.findIndex((candidate) => candidate.head === undefined && candidate.id >= target); if (kept < 0) kept = entries.length; entryOps.push(["p", ["entries"], 0, kept, [value]]); entries = [entry, ...entries.slice(kept)]; continue; } if (change.type !== "document" || change.conversationId !== id) continue; const kind = change.record.kind; if (!MOUNTED_KINDS.has(kind) || change.record.key !== undefined) continue; const path: NonEmptyPath = ["docs", kind]; const mounted = mount.docs.get(kind); if (change.value === null) { if (mounted?.id !== change.record.id) continue; mount.docs.delete(kind); docOps.push(["d", path]); } else if (mounted?.id === change.record.id && mounted.version === change.version) { for (const op of change.ops) docOps.push(prefixed(op, path)); } else { mount.docs.set(kind, { id: change.record.id, version: change.version! }); docOps.push(["s", path, change.value]); }}entries. Một head marker được đặt lên đầu và các entry trước head bị cắt. Phép toán của document được dời xuống dưới ["docs", kind]; document mới tạo hay đổi version thì được đặt cả giá trị. Spec cho ví dụ ["s", ["generation", "message"], v] trở thành ["s", ["docs", "pi.live", "generation", "message"], v] (spec.md:4796–4799). Về bảy loại phép toán của Chord, xem chương Chord.Áp lực ngược: quy tắc 100 frame
Commit không bao giờ chờ người xem. Một watch chậm (hoặc chưa start()) giữ tối đa 100 frame chưa giao; frame thứ 101 thay toàn bộ phần chưa giao bằng một frame duy nhất chứa trọn view mới nhất dưới dạng phép thay gốc ["r", value]:
advance(value: T, ops: readonly Op[], context: Context): void { if (this.#end !== undefined || this.#retired) return; if (value === null) this.#retired = true; if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) { this.#pending.length = 0; const replacement = this.#replace?.() ?? value; this.#pending.push({ value: replacement, ops: [["r", replacement as JsonValue]], context }); } else { this.#pending.push({ value, ops, context }); } if (this.#started) this.#schedule();}MAX_PENDING_WATCH_FRAMES = 100 (observation.ts:16). Không đo byte, không sao chép, không diff lại. Đây là quan sát hội tụ (convergent), không phải nhật ký kiểm toán: ai cần mọi chuyển trạng thái phải tự lưu chúng vào entry (spec.md:4742–4749).watchEvents: sự kiện kiểu coding agent
/** Experimental agent event, shaped like the coding agent's session events (spec §9.4). */export type AgentEvent = | SnapshotEvent | { type: "run_start"; inputs: readonly SubmissionId[] } | { type: "run_end"; inputs: readonly SubmissionId[] } | { type: "turn_start" } | { type: "turn_end" } | { type: "message_start"; message: Message } /** `usage` is the partial's current usage, as in the coding agent's JSON mode. */ | { type: "message_update"; usage: Usage; changes: readonly MessageChange[] } | { type: "message_end"; entry: EntryRecord } | ({ type: "tool_execution_start"; args: JsonObject } & ToolEventCall) | ({ type: "tool_execution_update"; /** A front trim and then an append of the retained window, or its replacement. */ output?: { trimStart?: number; append?: string } | { set: string }; details?: JsonValue; diagnostics?: readonly ToolDiagnostic[]; } & ToolEventCall) /** * A model-issued call ends with its result `entry`, a nested call with its `result`. Both are absent when the tool * task faulted or was orphaned, or when the call's slot left `pi.live` unfinished: its run ended, or its parent * settled first. */ | ({ type: "tool_execution_end"; entry?: EntryRecord; result?: NestedToolExecutionResult } & ToolEventCall) | { type: "inbox_update"; items: readonly QueuedItem[] } | { type: "submission"; record: SubmissionRecord } | { type: "auto_retry_start"; attempt: number; at: number; errorMessage: string } | { type: "auto_retry_end"; attempt: number } | { type: "deferred_poll"; pollAt: number } | { type: "entry_appended"; entry: EntryRecord } | { type: "agent_changed"; agent: AgentState } | { type: "usage_changed"; usage: UsageState } | { type: "task_failed"; taskId: TaskId; kind: string; message: string } | { type: "compaction_start"; taskId: TaskId; reason: CompactionReason; blocking: boolean } /** * The task's receipt tells whether it produced a summary; the summary entry has its own events. `error` is set when * the compaction failed, faulted, or was orphaned, as in `pi.live.compactionFailure`. */ | { type: "compaction_end"; taskId: TaskId; reason: CompactionReason; error?: string };Mỗi commit sinh một lô (batch) sự kiện, theo một thứ tự cố định: bắt đầu tool, message dở, cập nhật tool, thử lại; rồi entry theo thứ tự append (kết thúc của một tool đứng ngay trước message_start của kết quả của nó, như coding agent); rồi kết thúc compaction, lỗi task, turn_end, run_end; rồi submission; rồi inbox, agent, usage; cuối cùng compaction_start, run_start, turn_start (spec.md:4970–4983). message_update chỉ mang delta: phần text nối thêm, phần đối số tool nối thêm, nên partial được commit dày cũng không nhân dữ liệu trên đường truyền.
const views = conversationViews(harness);let watch!: CommittedWatch<readonly AgentEvent[]>;let snapshot!: SnapshotEvent;await views.attach( conversationId, async (initial, release, storage) => { // Generations whose held outcome already ended their turn, read on the line with the snapshot. const query = { conversationId, kind: "pi.generation", status: "completing" } as const; const completing = await scanAll((cursor) => storage.scanTasks(query, 100, cursor, context)); const held = new Set<TaskId>(completing.map((record) => record.id)); let current = initial; snapshot = snapshotOf(initial); // Batches are the watch's values; an overflow delivers a snapshot of the newest view instead. watch = new CommittedWatch<readonly AgentEvent[]>( [], release, (error) => views.report(error), () => [snapshotOf(current)], ); return { publication: (before, after, ops, publication, commitContext) => { current = after; const events = translate(conversationId, before, after, ops, publication, held); if (events.length > 0) watch.advance(events, [], commitContext); }, closeSession: (failure) => watch.closeSession(failure), }; }, context,CommittedWatch thay các lô chưa giao bằng một snapshot của state mới nhất. Spec gọi bộ chuyển đổi này là thử nghiệm, chỉ phủ một hội thoại (không phủ cây hội thoại nó sở hữu), và giao thức có thể đổi không báo trước (spec.md:4823–4828).Ví dụ 21-late-join.ts gắn một view và một luồng sự kiện vào giữa một run; 19-json.ts in toàn bộ luồng của một run dưới dạng sự kiện (--events) hoặc phép toán thô (--ops), như chế độ JSON của coding agent (chương Các chế độ chạy). Để quan sát một document riêng lẻ, dùng harness.documentState() hoặc harness.watchDoc(); cơ chế bên dưới được mổ xẻ ở chương storage.
Lần theo một input được trả lời#
README tóm vòng đời của một input bằng năm dòng:
One answered input, as entries and tasks:```textsubmit(input) → pi.user pi.generation → pi.system (only if the prompt or tools changed), pi.assistant (tool calls) pi.tool × n → pi.tool-result × n (owned by the generation, which waits for them) pi.generation → pi.assistant (answer) → submission done```Phần này mở rộng năm dòng đó thành từng commit, với code thật ở mỗi bước. Kịch bản: hội thoại gốc đang rảnh, đã cấu hình mô hình, registry có CodingTools (bốn tool read, write, edit, bash, không có section, tools/index.ts:31–39) và một extension có section preamble như ví dụ Coding của README (README.md:232–236). Người dùng gửi "Đọc notes.txt rồi tóm tắt". Mô hình gọi read một lần, nhận nội dung file, rồi trả lời. Phần bên trong task (reservation, wait, thử lại) chỉ được lướt qua; chương task đi sâu vào chúng.
Ví dụ minh hoạ: ID giả định storage mới, root() vừa tạo hội thoại 1 và năm document 2–6
(pi.live = 2, pi.inbox = 3, pi.usage = 4, pi.provider = 5, pi.agent = 6).
# ai commit ghi gì sự kiện watchEvents
C1 Submissions entry 7 pi.user · submission 8 placed message_start/end(7)
task 9 pi.generation pending {prepare, attempt 1} submission(8)
pi.live.run = {taskId 9, inputs [8]} run_start([8]) turn_start
C2 scheduler task 9 pending → running (không có)
C3 generation 9 entry 10 pi.system (baseline: section + toolsAdded) message_start/end(10)
(prepare) task 9 checkpoint {request, model, cutoff 10}
C4 generation 9 pi.live.generation = {attempt 1} (không có)
(request) ... các commit partial, mỗi ~100 ms ... message_start, message_update...
C5 generation 9 entry 11 pi.assistant (toolCall read) · pi.usage message_end(11) usage_changed
task 12 pi.tool pending, chủ là task 9
pi.live.tools = [read: pending] · bỏ generation
task 9 waiting on [12] (allSettled)
C6 scheduler task 12 pending → running (không có)
C7 tool 12 (call) pi.live.tools[0] running · checkpoint {execute, args} tool_execution_start
... execute(), output dở được commit theo nhịp ... tool_execution_update...
C8 tool 12 entry 13 pi.tool-result · slot done, entry 13 tool_execution_end
task 12 terminal {kind model, entryId 13} message_start/end(13)
C9 scheduler task 9 waiting → running (phase tools) (không có)
C10 generation 9 bỏ pi.live.tools · task 14 pi.generation pending turn_end turn_start
(tools) run.taskId 9 → 14 · task 9 terminal {entryId 11}
C11 scheduler task 14 pending → running (không có)
C12 generation 14 prompt và tool không đổi: không có pi.system mới (không có)
(prepare) task 14 checkpoint {request, cutoff 13}
C13 generation 14 pi.live.generation = {attempt 1}, rồi các partial message_start, message_update...
C14 generation 14 entry 15 pi.assistant (câu trả lời) · pi.usage message_end(15) turn_end
(answer) submission 8 done {entry 7, answer 15} run_end([8]) submission(8)
bỏ pi.live.run, generation · task 14 terminal usage_changed
→ Submission.wait() trả về {status "done", entry 7, answer 15}
C1: admission tạo input, run và generation đầu tiên
Đã xem ở mục Submission: hội thoại rảnh và inbox rỗng nên admitSubmission() append entry pi.user, tạo submission placed rồi gọi startRun(), vốn tạo task pi.generation thuộc hội thoại và đặt pi.live.run (generation.ts:752–769). Bốn bản ghi này lưu cùng nhau hoặc không lưu gì. Nếu tiến trình chết ngay sau C1, lần mở sau thấy một task pending và chạy nó; host gửi lại với cùng requestId thì nhận lại submission 8.
C2: scheduler giữ chỗ cho task
Scheduler theo dõi các commit, thấy task 9 có thể chạy, và trong một commit của chính nó đổi trạng thái sang running rồi mới gọi handler của phase (scheduler.ts:1540–1547). Trạng thái running được lưu là để lần mở sau biết task này đã bị cắt ngang và đếm số lần khởi động lại.
C3: prepare ghi system prompt theo vị trí
const view = await runtime.context(conversationId, context);const shown = replaySections(view.messages);let env: ExecutionEnv | undefined;try { env = await runtime.env(context);} catch (error) { if (context.abortSignal?.aborted) throw error; runtime.report(error, { type: "host", callback: "env" });}const input: PromptInput = { conversationId, agent, env, shown: Object.fromEntries(shown), read: runtime };const desired = await renderSections( agent.sections, input, shown, (error, section) => runtime.report(error, { type: "extension", ...optionalExtension(sectionExtension(agent, section.key)), }), context,);const entries = planSystemEntries(view, desired, agent.tools, runtime.now());await runtime.commit(async (tx) => { let cutoff = (await tx.scanEntries({ conversationId }, 1)).items[0]?.id; for (const entry of entries) cutoff = (await tx.appendEntry(SystemEntry, conversationId, entry)).id; if (cutoff === undefined) throw new Error(`Conversation ${conversationId} has no entries to send`); // Checked in this commit, so a compaction admitted during preparation counts. if (threshold === "background" && (await tx.doc(LiveDoc, conversationId)).compactions === undefined) { await createCompaction(tx, conversationId, { reason: "threshold" }); } const request = { attempt, ...(compacted === undefined ? {} : { compacted }), model, thinkingLevel, streamOptions: settings.stream, cutoff, }; return { status: "running", checkpoint: { phase: "request", ...request } };}, context);cutoff là entry mới nhất mà request sẽ thấy. Mô hình, mức suy nghĩ và tùy chọn stream được cố định trong checkpoint request, nên một lần gửi lại sau khôi phục dùng đúng chúng. Phần bị lược là kiểm tra ngưỡng compaction (generation.ts:177–189).C4: request, stream và partial
Phase request commit pi.live.generation = { attempt }, lấy ngữ cảnh tại cutoff, chạy hook beforeRequest, rồi gọi mô hình với sessionId lấy từ pi.provider (generation.ts:209–236). Trong lúc stream, partial được commit vào pi.live.generation.message với nhiều nhất một commit đang bay (generation.ts:441–498). Đầu phase, convertPartial() biến một partial còn sót từ lần thử bị cắt ngang thành entry pi.assistant có stop reason aborted (generation.ts:430–439): đó chính là "phần trả lời dở được giữ và đánh dấu bị huỷ" trong bài công bố.
C5: câu trả lời gọi tool mở một vòng tool
await runtime.commit(async (tx): Promise<Next> => { const live = await tx.doc(LiveDoc, conversationId); const entry = await appendAssistant(tx, conversationId, message); const slots: ToolSlot[] = []; const tools: TaskId<ToolTaskResult>[] = []; const pending: string[] = []; for (const call of calls) { if (!offered.has(call.name)) { const unavailable = harnessError("tool_unavailable", `Tool ${call.name} is not available`); const result = await appendToolResult(tx, conversationId, call, unavailable, { timestamp: runtime.now() }); slots.push({ callId: call.id, name: call.name, status: "done", entry: result.id }); continue; } if (sequential && tools.length > 0) { pending.push(call.id); slots.push({ callId: call.id, name: call.name, status: "pending" }); continue; } const taskId = await createToolTask(tx, runtime, entry.id, call.id); tools.push(taskId); slots.push({ callId: call.id, name: call.name, taskId, status: "pending" }); } delete live.generation; live.tools = slots; const checkpoint = { phase: "tools", assistant: entry.id, tools, pending } as const; return { status: "waiting", checkpoint, on: tools, policy: "allSettled" };}, context);tool_unavailable. Mọi lời gọi khác có một task pi.tool mà chủ là generation (ownership: { kind: "task" }, generation.ts:330–337). Generation không chạy code nào trong lúc waiting, và vẫn giữ pi.live.run.C7 và C8: tool ghi ý định, chạy, rồi ghi kết quả
await runtime.commit(async (tx) => { const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId); if (slot !== undefined) slot.status = "running"; // A nested slot shows the arguments the call runs with, which repair, hooks, and coercion may have changed. // Compared with the input, which the slot got at admission: reading the draft would track every leaf. const changed = task.input.kind === "nested" && !jsonEqual(task.input.call.arguments as JsonValue, final); if (changed && slot !== undefined && "parentTaskId" in slot) slot.arguments = final; const intent = { phase: "execute", arguments: final, replay: tool.replay ?? "unsafe" } as const; return { status: "running", checkpoint: intent };}, context);await run(runtime, task.input, call, tool, final, context);execute: tool chạy lại chỉ khi cả chính sách đã lưu lẫn chính sách hiện tại là safe; nếu không, mô hình nhận kết quả lỗi interrupted kèm output đã commit (tool.ts:180–198). const timing = { timestamp: runtime.now(), durationMs }; const entry = await appendToolResult(tx, conversationId, call, result, timing); if (slot !== undefined && !("parentTaskId" in slot)) { finishSlot(slot); slot.entry = entry.id; } // Tools build control objects freely; drop keys set to undefined so the task result is strict JSON. const control = result.control === undefined || ending.status !== "completed" ? {} : { control: copyJson(result.control as JsonValue, { omitUndefinedProperties: true }) as ToolControl }; settled = { kind: "model", entryId: entry.id, ...control };return { status: "terminal", outcome: { status: "completed", result: settled } };appendToolResult() ghi entry có data.diagnostics và cộng usage của tool vào pi.usage trong cùng commit (tool.ts:885–913).C10: hết vòng tool, giao run cho generation kế tiếp
await runtime.commit(async (tx): Promise<Next> => { const boundary = await prepareBoundary(tx, conversationId, runtime.settings); if (added.length > 0) await addTools(tx, conversationId, added); const live = await tx.doc(LiveDoc, conversationId); const now = runtime.now(); if (terminate || handoff !== undefined) { } else { const { users, reset } = await applyBoundary(tx, boundary, "postTools", now); if (reset) { // The queued reset cut the run's context before an answer. endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "reset" }); if (users.length > 0) await startRun(tx, conversationId, live, users); } else { delete live.tools; delete live.nestedTools; if (live.run?.taskId === runtime.taskId) live.run.inputs.push(...users); handOver(live, runtime.taskId, await createGeneration(tx, conversationId)); } } return { status: "terminal", outcome: { status: "completed", result: { entryId: assistant } } };}, context);postTools là nơi tin nhắn steer đang chờ được đặt vào transcript và nhập vào run hiện tại (live.run.inputs.push). Generation 9 kết thúc completed; quyền điều khiển run chuyển sang generation 14 mà không có lúc nào hội thoại "rảnh" (generation.ts:771–774).Generation 14 lặp lại prepare; lần này prompt và tool không đổi nên planSystemEntries() trả về mảng rỗng và không có entry pi.system mới: prompt cache của provider vẫn ấm.
C14: câu trả lời cuối chốt submission
await runtime.commit(async (tx): Promise<Next> => { // Queue modes are read on the Session line, when the boundary is decided. const boundary = await prepareBoundary(tx, conversationId, runtime.settings); const live = await tx.doc(LiveDoc, conversationId); const entry = await appendAssistant(tx, conversationId, message); const result: Next = { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } }; const { users, reset } = await applyBoundary(tx, boundary, "final", runtime.now()); if (continuation !== undefined && users.length === 0 && !reset) { const user = { role: "user", content: continuation, timestamp: runtime.now() } as const; await tx.appendEntry(UserEntry, conversationId, { model: [user] }); handOver(live, runtime.taskId, await createGeneration(tx, conversationId)); delete live.generation; return result; } endRun(tx, live, runtime.taskId, { status: "done", answer: entry.id }); if (users.length > 0) await startRun(tx, conversationId, live, users); return result;}, context);applyBoundary(..., "final") đặt nó và startRun() mở run kế tiếp ngay trong commit này./** * End the run owned by `taskId`: settle each of its inputs and remove `run`. Always removes `generation`, `tools`, and * `nestedTools`, whose presentation belongs to the ending run. */export function endRun(tx: Tx, live: Draft<LiveState>, taskId: TaskId, settlement: SubmissionSettlement): void { if (live.run?.taskId === taskId) { for (const id of live.run.inputs) tx.settleSubmission(id, settlement); delete live.run; } delete live.generation; delete live.tools; delete live.nestedTools;}Commit C14 chứa bản ghi submission 8 ở trạng thái done. Bộ quan sát commit của Submissions thấy nó trong publication và giải phóng mọi người đang wait() (submissions.ts:145–150). Host đọc câu trả lời bằng tx.entry(AssistantEntry, settled.answer), như Quick Start (README.md:62–67). Ví dụ chạy được gần nhất với trace này là 17-coding-tools.ts: một lượt dùng tool trên JSONL, để lại thư mục storage để bạn mở ra xem.
API công khai và các ví dụ#
Gốc package (@earendil-works/pi-durable) xuất mọi thứ cần để dựng một harness; phần đầu của index.ts:
export { defineDoc, defineDocFamily } from "./documents.ts";export { AssistantEntry, CompactionEntry, defineEntry, ResetEntry, SystemEntry, ToolResultEntry, UserEntry,} from "./entries.ts";export { ConversationBusy, ReadAfterWrite, SessionFailed, StorageRequestError } from "./errors.ts";export { AgentDoc, configure, DEFAULT_COMPACTION_POLICY, DEFAULT_PROGRESS_POLICY, DEFAULT_RESTART_LIMIT, DEFAULT_RETRY_POLICY,} from "./harness/agent.ts";export { type CompactionCheckpoint, type CompactionInput, CompactionTask, type SummaryRequest,} from "./harness/compaction.ts";export { defineExtension, defineTool, hook, section, wrapSection, wrapTool } from "./harness/define.ts";| Nhóm | Giá trị xuất ra | Ở đâu |
|---|---|---|
| Mở và chạy | Harness, DEFAULT_HARNESS_IDENTITY, createRegistry, createSession, MemoryStorage, ROOT_CONVERSATION_ID | index.ts:41, :53, :126–127, :216 |
| Định nghĩa | defineDoc, defineDocFamily, defineEntry, defineExtension, defineTool, hook, section, wrapTool, wrapSection, defineTask | index.ts:26, :128 |
| Entry dựng sẵn | UserEntry, AssistantEntry, SystemEntry, ToolResultEntry, ResetEntry, CompactionEntry | index.ts:2–10 |
| Document dựng sẵn | AgentDoc, LiveDoc, InboxDoc, ProviderDoc, UsageDoc, NestedCallDoc | index.ts:42–52, :124 |
| Task dựng sẵn (để đăng ký hook) | GenerationTask, ToolTask, CompactionTask | index.ts:35–40, :57–64 |
| Cấu hình | configure, DEFAULT_RETRY_POLICY, DEFAULT_COMPACTION_POLICY, DEFAULT_PROGRESS_POLICY, DEFAULT_RESTART_LIMIT | index.ts:12–19 |
| Quan sát, telemetry | watchEvents (thử nghiệm), DURABLE_TELEMETRY_SCHEMA | index.ts:27–34, :56 |
| Lỗi | ConversationBusy, ReadAfterWrite, SessionFailed, StorageRequestError | index.ts:11 |
| Kiểu | Bề mặt Harness (Conversation, Submission, Agent, HarnessOptions, ...) và bề mặt Session/storage (Tx, Storage, EntryRecord, TaskRecord, ...) | index.ts:65–123, :129–215 |
Phần còn lại tách thành các subpath để không kéo phụ thuộc không cần (package.json:8–111): /tools (read, write, edit, bash, CodingTools), /codemode, /tool-search, /env và /env/node, /images (và /images/node, /images/cloudflare), các backend /storage/memory, /storage/indexeddb, /storage/jsonl(/node), /storage/sqlite(/node, /cloudflare), và /testing. Gốc package tải TypeBox vì task tool kiểm tra đối số; README ước khoảng 23 MB RSS đỉnh khi không bundle, khoảng 4 MB khi bundle có tree-shaking (README.md:817).
Các ví dụ trong test/examples
Ba mươi hai ví dụ chạy được bằng node --conditions=source --experimental-strip-types test/examples/<file> từ thư mục package (README.md:839–843); đa số dùng provider giả (faux) của pi-ai, vài ví dụ dùng OpenAI khi có OPENAI_API_KEY. Mười bốn ví dụ đầu dạy từng tầng, từ dưới lên:
| Ví dụ | Dạy gì |
|---|---|
| 00-conversation | Session trần trên MemoryStorage; mọi lần ghi nằm trong commit(); hội thoại ownerless |
| 01-documents | Document rewindable; snapshot() và snapshotAsOf() tại từng entry |
| 02-forks | Fork tại một entry: thấy entry của cha tới đó; document asOf được sao chép độc lập |
| 03-owned-conversations | Task nền sở hữu một hội thoại con; task, hội thoại và document registry trong một commit |
| 04-chord-state | documentState(): state Chord chỉ đọc, không bao giờ tạo document |
| 05-watches | watchDoc(): revision gốc rồi frame tuần tự, quy tắc 100 frame |
| 06-harness | Harness với registry; root() với agent và init; agent lưu tên, giải khi đọc |
| 07-configuration | configure(), settings có getter, metadata riêng trên tool, gỡ rồi cài lại extension |
| 08-harness-conversations | Handle không giữ state, entry có kiểu, createConversation() và fork() với agent |
| 09-context | Dẫn xuất ngữ cảnh: head, edit, message bị loại, thứ tự kết quả tool, kết quả tổng hợp |
| 10-registry-reload | Thay extension tại chỗ, wrapper theo tên, gỡ |
| 11-extension-state | Extension có document riêng: tool ghi, section hiển thị cho mô hình |
| 12-tasks | Task tự định nghĩa: lưu ý định, làm, lưu kết quả |
| 13-recovery | Đóng giữa chừng trên SQLite, mở lại, task chạy tiếp; memo chống lặp hiệu ứng; sleep bền |
Mười tám ví dụ sau (14 đến 31) là code kiểu sản phẩm, có bảng mô tả riêng trong README (README.md:845–864): một câu hỏi và câu trả lời (14), system prompt (15), mô hình thật (16), tool lập trình trên JSONL (17), chế độ in (18) và JSON (19) như pi -p và pi --mode json, inbox (20), vào muộn (21), subagent tiền cảnh (22) và subagent nền bền qua khởi động lại (23), task con (24), compaction (25), một coding agent nhỏ (26), plan mode (27), reviewer (28), sandbox theo hội thoại (29), thay tool cho một số hội thoại (30), và reload rồi khởi động lại (31).
Quyết định thiết kế#
Lưu trước, hiển thị sau, không có ngoại lệ
Bất biến số 3 của đặc tả: "All visible progress is durable. There is no volatile publication path" (spec.md:67), và "visible-undurable publication" đứng trong danh sách không làm (spec.md:5606–5618). Lý do là các mục tiêu nhiều người dùng và vào muộn: nếu UI được phép thấy thứ chưa lưu, một client kết nối lại sau crash có thể thấy "quá khứ bị viết lại". Khi mọi thứ hiển thị đều đã lưu, kết nối lại chỉ là lấy state hiện tại. Cái giá là tiến độ đi theo nhịp commit, và mỗi token stream ra đều thành một lần ghi; Pi Durable trả giá đó bằng throttle 100 ms, delta của Chord và chính sách checkpoint của pi.live để chuỗi delta luôn ngắn.
Mọi thứ harness chạy là một task
Bài công bố viết: "Everything the harness runs, from calling the model to executing a tool, is a task." Vòng lặp của pi-agent-core (chương agent-core) là một hàm async giữ state trong bộ nhớ: chết là mất. Ở đây mỗi bước là một phase của một máy trạng thái, và state cần để làm tiếp nằm trong checkpoint đã commit. Generation sở hữu các task tool của vòng nó; hội thoại sở hữu generation; huỷ chạy từ dưới lên. Thiết kế này cho khôi phục từ checkpoint cuối, nhưng buộc mọi hiệu ứng ngoài phải nghĩ về chạy lại: tool phải khai báo replay, task phải "lưu ý định, làm, lưu kết quả" (12-tasks.ts:10–14). Tên mã cũ của harness, Pico, vẫn còn trong vài chú thích (sqlite/storage.ts:151).
Lưu tên, không lưu code
pi.agent chỉ chứa tên extension và tên tool; registry nói tiến trình này chạy được gì lúc này (spec.md:843–848). Nhờ vậy reload không cần migration và một hội thoại cũ mở được trong một tiến trình thiếu vài extension: tên chưa cài bị bỏ qua khi giải, và có hiệu lực lại khi được cài. Cái giá: không có gì báo lỗi khi một extension được chọn mà không có mặt, nên một extension bảo vệ (permission hook) vắng mặt nghĩa là chạy không có bảo vệ.
Prompt là bản vá theo vị trí
Thay vì dựng lại system prompt mỗi request, transcript giữ đúng những gì mô hình đã thấy, ở đúng vị trí, và chỉ append phần khác. Lựa chọn này phục vụ prompt cache và tính di chuyển giữa provider: chuỗi section đã render là bản ghi có thẩm quyền, kể cả khi renderer sau này thay đổi (spec.md:3780–3784). Cái giá: section phải tất định, và đổi thứ tự tốn hai entry.
Checkpoint là chính sách của định nghĩa, không phải heuristic của backend
Đặc tả cấm storage tự đếm byte hay tự chèn checkpoint, và coi một định nghĩa không bao giờ checkpoint là lỗi của định nghĩa (spec.md:1569–1576); "automatic checkpoint heuristic" nằm trong danh sách không làm (spec.md:5612). Người định nghĩa document biết nó thay đổi thế nào: pi.live chọn "khi rảnh", pi.inbox chọn "khi rỗng". Bản 1.4.0 bỏ giá trị mặc định để buộc mỗi định nghĩa trả lời câu hỏi đó. Cơ chế base và delta được mổ xẻ ở chương storage.
Sự kiện là bộ chuyển đổi, không phải nguồn sự thật
Không có nhật ký sự kiện ngữ nghĩa trong lõi Session (spec.md:5610). watchEvents() dẫn xuất sự kiện từ commit và có thể bỏ qua chuyển trạng thái trung gian khi tràn; kết quả chính xác của một input luôn đến từ Submission.wait(). Một TUI vẽ từ ConversationView, sự kiện chỉ dùng cho hiệu ứng chuyển cảnh (spec.md:4986–4987).
Một tiến trình sở hữu một storage
Không có khoá liên tiến trình (README.md:790), và trộn CRDT hay nhiều người ghi ngoại tuyến không nằm trong phạm vi. "Nhiều người cùng điều khiển một agent" nghĩa là nhiều client nói chuyện với một tiến trình sở hữu storage, như một Durable Object cho mỗi session trong bài "Pi Durable on Cloudflare".
Cạm bẫy và trường hợp biên#
- Append entry thô vào hội thoại đang bận. Chỉ Harness được ghi ngữ cảnh mô hình vào một hội thoại có run; một entry chen vào lúc generation đang chuẩn bị có thể làm lệch entry
pi.systemcủa nó. Dùng write submission (spec.md:5470–5473). - Input gửi giữa run mặc định là follow-up. Nó chờ tới khi run trả lời mới được đặt. Muốn chen vào vòng tool hiện tại thì
whenBusy: "steer". - Write chỉ để hiển thị vượt lên trước. Nó được append ngay, nên có thể đứng trước các mục gửi sớm hơn còn đang chờ, trừ khi có reset đang chờ (README.md:517).
- Hàng đợi sau một run thất bại. Mặc định các follow-up nằm lại trong
pi.inbox, vàwait()của chúng không trả về, cho tới khi có input mới hoặc write xếp hàng chạy boundary (spec.md:5474–5480). - Huỷ
wait()không rút submission. Rút làsubmission.abort(), và chỉ khi cònqueued. root()chỉ ápagentvàinitmột lần. Lần gọi sau, kể cả sau khi đổi code, bỏ qua chúng; muốn đổi thìconfigure().- Lựa chọn và bản sao.
{ add, remove }luôn sửa lựa chọn mặc định của host; ghi nó vào một subagent đã chép lựa chọn từ chủ sẽ thay lựa chọn đã chép. Bản saopi.agentcủa hội thoại do task sở hữu là bản sao một lần (spec.md:5571–5581). - Watch hội tụ, không kiểm toán. Quá 100 frame thì các trạng thái trung gian bị gộp. Cần mọi chuyển trạng thái thì ghi chúng thành entry (spec.md:5551–5555).
- Handle không sống qua lần mở lại.
Conversation,Submissionthuộc Harness đã trả chúng; sau khi mở lại, lấy lại bằngharness.conversation(id)vàharness.submission(id). - Đặt tên với tiền tố
pi.Không bị chặn (trừ kind của input), nhưng va chạm với hành vi dựng sẵn, ví dụ một entry tự đặt kindpi.systemsẽ bị phát lại như system message.
Đọc code ở đâu#
Mọi đường dẫn tính từ packages/durable/. Thứ tự dưới đây đi từ khái niệm tới hiện thực.
| # | File | Đọc để hiểu |
|---|---|---|
| 1 | README.md | Quick Start, Concepts, rồi các mục Busy Conversations, Watching, Errors |
| 2 | docs/spec.md §1–2 | Bất biến, bản ghi lõi, bề mặt công khai của Harness (đặc tả là chuẩn, code thắng khi lệch) |
| 3 | src/types.ts | Mọi bản ghi: ID, hội thoại, entry, submission, task, document, Tx, Session, Storage |
| 4 | src/entries.ts, src/documents.ts | Token entry và document |
| 5 | src/harness/types.ts | Bề mặt Harness: Conversation, Submission, Extension, AgentState, HarnessOptions, hook |
| 6 | src/harness/harness.ts | Harness.open, #create, hook tạo hội thoại, handle |
| 7 | submissions.ts, inbox.ts | Admission, boundary, wait, abort |
| 8 | live.ts, provider.ts, usage.ts | Document dựng sẵn và hàm tiện ích của chúng (endRun, recordUsage) |
| 9 | registry.ts, define.ts, agent.ts | Registry, giải agent, settings |
| 10 | prompt.ts, context.ts | Entry pi.system theo vị trí, dẫn xuất ngữ cảnh |
| 11 | view.ts, events.ts | Mount view và bộ chuyển đổi sự kiện |
| 12 | generation.ts, tool.ts | Chỉ các commit trong trace ở trên; phần còn lại ở chương task |
| 13 | src/index.ts, package.json | Bề mặt xuất ra và các subpath |
| 14 | test/examples/ | 00 tới 13 theo thứ tự, rồi 14, 17, 20, 21 |
Tóm tắt#
- Pi Durable là một harness agent bền, thử nghiệm, phát hành cùng Pi 1.0; nó không dùng
AgentSessionhaypi-agent-core, và không thay thế coding agent. - Quy tắc lõi: entry bất biến, bản ghi task và document Chord được commit nguyên tử; chỉ state đã commit là quan sát được.
- Hội thoại là transcript entry bất biến;
parentcho fork,ownercho subagent; head marker định nghĩa ngữ cảnh đang hoạt động. - Document là JSON có kiểu với phạm vi, lịch sử, chính sách fork và chính sách checkpoint tường minh; năm document dựng sẵn mang agent, danh tính provider, state đang chạy, inbox và chi phí.
submit()nhận input hoặc write bền rồi trả về;requestIdcho exactly-once; bận thì xếp hàng thành steer hoặc follow-up, đặt vào ở boundary.- Code nằm trong extension của registry tiến trình; hội thoại lưu tên, agent được giải lại mỗi lần dùng; system prompt là các entry
pi.systemtheo vị trí, chỉ gửi phần thay đổi. - UI quan sát bằng
viewState(),watch()hoặcwatchEvents(), tất cả dẫn xuất từ commit, hội tụ, giới hạn 100 frame.
18Durable: session, commit, document và storage#
Chương trước dựng mô hình khái niệm của Pi Durable từ phía người dùng thư viện: hội thoại, entry, document, submission, agent. Chương này đi xuống một tầng, tới phần bảo đảm lời hứa "lưu trước, hiển thị sau": Session với dòng commit duy nhất, transaction và cách nó gom một commit, document bên trong (tracker, base, delta, lịch sử), fork, quan sát, giao diện Storage và năm backend, các quy tắc lỗi, cách chạy trên Cloudflare Durable Object, và cuối cùng là những dòng cụ thể một commit ghi xuống đĩa. Task và scheduler nằm ở chương task; delta và state nhân bản của Chord nằm ở chương Chord.
Vai trò: tầng nằm dưới Harness#
Harness là một SessionImpl có thêm scheduler, submission và view (harness.ts:207). Session cũng dùng được một mình qua createSession(storage, options), như các ví dụ 00 đến 05: không có task chạy, không có generation, chỉ có bản ghi, document và commit (session.ts:71–87).
/** * Session kernel: one mutation line, the loaded document tracker cache, and committed publication. * * The cache is bounded by use: a document with no watch or document state attached leaves it once it was not read or * written for the retention period, and the next access loads it from Storage. Eviction runs as a job on the mutation * line, so no transaction ever holds a tracker that leaves. * * Only committed state is observable. Every commit callback, preparation, Storage settlement, adoption, and * publication enqueue runs while the line is held; listeners run later. * * Storage failures are final. Every Storage call goes through one guard: the first error a Storage method throws, other * than for a cancelled caller, fails the Session, as does a commit it cannot adopt. That call gets the error; every later * call and every Storage call still underway gets `SessionFailed`; close listeners run at once with the error, and the * Session then closes itself, Storage included, and `closed` settles. Nothing retries: transient errors are the * Storage's to retry. Reopening recovers from what was committed. */export class SessionImpl implements Session {Bên dưới Session là Storage, ranh giới lưu trữ. Hợp đồng chia việc rõ ràng: Session chịu trách nhiệm về ý nghĩa (bản ghi hợp lệ, tổ tiên đúng, chuyển trạng thái hợp lệ) và tuần tự hoá các commit; Storage chịu trách nhiệm về tính nguyên tử, quyền sở hữu ID toàn cục, tính bất biến của hội thoại và entry, sự nhất quán của bản ghi document, và trả về giá trị đã tách (detached) khỏi bộ nhớ của nó (types.ts:1166–1178). Vì Session đã giữ một dòng commit, backend không cần thêm mutex phía người gọi (spec.md:5231–5233).
Mô hình tư duy: đường đi của một commit#
session.commit(callback) hoặc conversation.commit, runtime.commit, api.commit
└─ #enqueue ── dòng commit: một promise chain, mỗi lúc một việc
└─ #runCommit
1 new Transaction(host, context, scope)
2 await callback(tx) tx.appendEntry · tx.doc · tx.createTask · tx.settleSubmission ...
│ đọc bảng trước, ghi bảng sau; draft document là của riêng tx
├─ ném lỗi ──────────────▶ settleFailure(): huỷ mọi draft, không ghi gì
3 tx.settleSuccess() niêm phong; prepare() mọi draft → giá trị mới + Op[]
│ #assemble(): kiểm tra, rồi checkpointWhen → base hay delta
└─ không có gì để ghi ───▶ discard(), xong
4 storage.commit(writes) một lô nguyên tử → Seq
└─ ném lỗi ──────────────▶ Session thất bại (guard), không công bố gì
5 tx.adopt(seq) đổi con trỏ tracker sang giá trị đã chuẩn bị
6 #publish(seq, ...) listener nội bộ (scheduler, submission, view) rồi listener của host,
chạy đồng bộ chỉ để chụp giá trị bất biến
7 thả dòng; callback của watch và document state chạy sau, ngoài dòng
Ba từ vựng của chương: dòng commit (mutation line) là hàng đợi tuần tự mà mọi commit và mọi lần đọc nhiều bước đều đi qua; publication là tập thay đổi bất biến của một commit thành công (CommitPublication, types.ts:900–904); incarnation là một kiếp sống của một document, từ lúc tạo tới lúc nghỉ hưu, có ID riêng không bao giờ dùng lại.
Session: một dòng commit nguyên tử#
"Một dòng commit" nghe như một khái niệm lớn, nhưng hiện thực chỉ là một chuỗi promise:
#enqueue<T>(job: () => Promise<T>): Promise<T> { const run = this.#tail.then(job); this.#tail = run.then( () => undefined, () => undefined, ); return run;}commitWith<T>(change: (tx: Transaction) => T | Promise<T>, context: Context, scope?: TransactionScope): Promise<T> { try { this.assertUsable(); } catch (error) { return Promise.reject(error); } return this.#enqueue(() => this.#runCommit(change, context, scope));}/** Internal: run a read-only job on the mutation line so multi-read derivations observe one committed state. */readOnLine<T>(job: () => Promise<T>): Promise<T> { try { this.assertUsable(); } catch (error) { return Promise.reject(error); } return this.#enqueue(async () => { this.#assertHealthy(); return job(); });}commitWith() là bản nội bộ của commit(), nhận thêm scope (hội thoại mặc định cho createTask() và task được đóng dấu byTaskId). readOnLine() dùng khi một lần đọc gồm nhiều truy vấn, như dựng mount view hay chụp biên ngữ cảnh, để chúng thấy cùng một trạng thái.Toàn bộ một commit nằm trong #runCommit(). Nửa đầu chạy callback và gom lô ghi:
async #runCommit<T>( change: (tx: Transaction) => T | Promise<T>, context: Context, scope?: TransactionScope,): Promise<T> { this.#assertHealthy(); context.abortSignal?.throwIfAborted(); const tx = new Transaction(this.#host, context, scope); let result: T; try { result = await change(tx); // A callback that caught a failed read must not commit, nor succeed as if it had. this.#assertHealthy(); } catch (error) { await tx.settleFailure(); this.#sweepDocuments(); throw error; } let writes: readonly StorageWrite[]; try { writes = await tx.settleSuccess(); } catch (error) { // Settlement aborted every change already. this.#sweepDocuments(); throw error; } if (writes.length === 0) { tx.discard(); this.#sweepDocuments(); return result; }context.abortSignal?.throwIfAborted() chỉ được kiểm tra trước khi bắt đầu; đây là điểm nhận (admission) của commit. Sau đó kiểm tra sức khoẻ lần nữa: một callback đã bắt và nuốt lỗi đọc storage không được commit như thể không có gì. Commit không có gì để ghi thì không tới storage, không tốn số thứ tự Seq.Nửa sau ghi lô, nhận nó vào bộ nhớ, rồi công bố:
let seq: Seq; try { // Once admitted, caller cancellation does not interrupt Storage settlement. seq = await this.#storage.commit(writes, withoutAbortSignal(context)); } catch (error) { // The guard has failed the Session. tx.discard(); throw error; } let documents: DocumentCommitChange[]; try { documents = tx.adopt(seq); } catch (error) { // Storage already committed; a failed adoption leaves memory behind durable state. this.fail(error); throw error; } for (const document of documents) this.#touchDocument(addressId(document.record)); this.#publish(seq, writes, documents, context); this.#sweepDocuments(); return result;}withoutAbortSignal(context): một khi đã nhận, huỷ phía người gọi không cắt ngang việc ghi. Nếu storage ném lỗi, lớp chặn lỗi đã làm Session thất bại và không có gì được công bố. Nếu storage đã ghi mà việc nhận vào bộ nhớ (adopt) thất bại, bộ nhớ đã tụt sau storage, nên Session cũng thất bại. if (changes === undefined) return; for (const document of documents) changes.push(document); const publication: CommitPublication = { seq, changes }; // The commit is durable: a failing listener neither fails the commit nor skips the others. for (const listener of [...this.#internalListeners]) { contained( () => listener(publication, context), (error) => this.fail(error, context), ); } for (const listener of [...this.#commitListeners]) { contained( () => listener(publication, context), (error) => this.report(error, LISTENER, context), ); }}onReport. Commit đã bền thì vẫn đứng (spec.md:5330–5333). Ngay trước đó, publication phát sự kiện telemetry pi.durable.entry và pi.durable.submission (session.ts:776–801).Đây là chỗ hiện thực bất biến "chỉ state đã commit là quan sát được": view, watch, waiter của submission và scheduler đều tiến lên từ publication, và publication chỉ tồn tại sau khi storage.commit() trả về. Listener phải chạy đồng bộ và không được gọi API của Session; callback của watch và document state được lên lịch chạy sau (microtask), ngoài dòng (spec.md:1697–1711).
Đóng Session cũng đi qua dòng (session.ts:495–529): niêm phong việc nhận commit mới, chạy listener đóng, gọi beforeClose() (Harness dùng nó để báo hiệu và chờ mọi invocation của task, harness.ts:396–399), rồi một việc trên dòng xoá cache và đóng storage. Các commit đã nhận trước khi niêm phong vẫn hoàn tất. closed trả về { reason: "closed" } hoặc { reason: "failed", error }.
Transaction: một commit chứa gì#
Thứ một commit gửi xuống storage là một mảng StorageWrite:
/** One record or document mutation in an atomic storage commit. */export type StorageWrite = | { readonly type: "conversation"; readonly value: ConversationRecord } | { readonly type: "entry"; readonly value: EntryRecord } | { readonly type: "task"; readonly value: TaskRecord<JsonValue, JsonValue, JsonValue> } | { readonly type: "submission"; readonly value: SubmissionRecord } | { readonly type: "document.create"; readonly record: DocumentCreate; readonly content: Extract<DocumentContent, { readonly kind: "base" }>; } | { readonly type: "document.copy"; readonly record: DocumentCreate; readonly source: DocumentCopySource } | { readonly type: "document.change"; readonly id: DocumentId; readonly content: DocumentContent; } | { readonly type: "document.retire"; readonly id: DocumentId };Transaction gom các lệnh đó trong lúc callback chạy. Trạng thái của nó cho thấy cách gom:
/** Atomic batch; conversation and entry writes stage eagerly, while task and document writes assemble later. */readonly #writes: StorageWrite[] = [];readonly #createdConversationIds = new Set<ConversationId>();readonly #forkSourceConversationIds = new Set<ConversationId>();readonly #forkSourceDocumentIds = new Set<DocumentId>();/** One entry per task touched by a public read, candidate write, or document-owner lookup. */readonly #tasksById = new Map<TaskId, TransactionTask>();/** Submissions created by this transaction, by ID. */readonly #submissions = new Map<SubmissionId, SubmissionRecord>();/** Submission settlements and placements in staging order; resolved against the latest candidate record during assembly. */readonly #submissionChanges: { readonly id: SubmissionId; readonly change: SubmissionChange }[] = [];/** Write and publication plans of every staged incarnation, built during assembly. */readonly #plans: DocumentPlan[] = [];/** Every document acquisition or retirement marker in staging order. */readonly #documents: DocumentEntry[] = [];/** Latest transaction-local incarnation or retirement marker at each logical address. */readonly #latestDocumentByAddress = new Map<string, DocumentEntry>();#writes ngay khi gọi, theo đúng thứ tự gọi. Task và submission nằm trong map theo ID: ghi nhiều lần trong một commit thì chỉ bản ghi ứng viên cuối cùng được gửi. Document được lên kế hoạch lúc niêm phong.Đọc bảng trước, ghi bảng sau
#read<T>(method: string, read: () => Promise<T>): Promise<T> { try { this.#assertOpen(); if (this.#hasTableWrite) throw new ReadAfterWrite(method); return this.#track(read()); } catch (error) { return Promise.reject(error); }}#write<T>(write: () => Promise<T>): Promise<T> { try { this.#assertOpen(); this.#hasTableWrite = true; return this.#track(write()); } catch (error) { return Promise.reject(error); }}Vì sao có quy tắc này? Mọi lần đọc bảng trong transaction đi thẳng tới storage, tức trạng thái đã commit; các lần ghi trong transaction chưa nằm ở đó. Nếu cho đọc sau khi ghi, code sẽ dễ tưởng mình đọc được thứ mình vừa ghi. Thay vào đó, các hàm tạo trả về luôn bản ghi vừa tạo, và lần đọc sau lần ghi đầu tiên ném ReadAfterWrite (spec.md:1728–1734). Document không bị quy tắc này: draft của chính transaction là đọc-thấy-ghi (read-your-writes). Một số tra cứu nội bộ cũng không bị tính, như kiểm tra task chủ dựa trên bản ghi ứng viên mới nhất (transaction.ts:1003–1006), và settleSubmission(), placeSubmission(), vốn chỉ ghi lại một thay đổi để giải quyết lúc gom lô (transaction.ts:466–481). Đó là lý do prepareBoundary() phải được gọi đầu commit: nó đọc head marker, một lần đọc bảng.
Cấp ID và đóng dấu entry
#appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord> { return this.#write(async () => { await this.#requireConversation(conversationId); this.#assertOpen(); const id = await this.#host.storage.mintId<EntryId>(); this.#assertOpen(); const { head, ...rest } = value; // Undefined `head` and `byTaskId` are omitted by the copy. const record = copyJson( { ...rest, id, conversationId, head: head === "self" ? id : head, byTaskId: this.#scope.taskId, }, TABLE_JSON_COPY_OPTIONS, ) as unknown as EntryRecord; this.#writes.push({ type: "entry", value: record }); return record; });}copyJson(..., { omitUndefinedProperties: true }) vừa tách bản ghi khỏi đối tượng của người gọi vừa bỏ trường undefined, để bản ghi là JSON nghiêm ngặt. Bản ghi trả về là giá trị bất biến dùng chung với listener của commit.ID đến từ storage.mintId(), gọi bất đồng bộ vì một storage từ xa có thể cấp ID bền (spec.md:1359–1361). Các backend làm việc này khác nhau. MemoryStorage và SqliteStorage dùng một bộ đếm trong bộ nhớ (memory.ts:427–431, sqlite/storage.ts:212–216); SQLite ghi next_id xuống cùng mỗi commit. IndexedDbStorage ghi mỗi lần cấp trong một transaction riêng (indexeddb.ts:442–452). Hệ quả với SQLite: một ID đã cấp cho một transaction bị huỷ sau commit cuối cùng có thể được cấp lại sau khi mở lại; một ID đã commit thì không bao giờ, đúng bất biến "ID không bao giờ bị dùng lại sau một lần ghi đã commit" (spec.md:69). Hội thoại gốc không xin ID: createRootConversation() dùng ID 1 dành sẵn (transaction.ts:291–294), và idFromNumber() chỉ gắn nhãn kiểu, không làm gì lúc chạy (ids.ts:3–6).
tx.createTask() theo cùng khuôn: kiểm task chủ (tồn tại; không completing, không terminal, không bị đánh dấu huỷ; task con không được là task nền và phải ở cùng hội thoại với chủ), tính checkpoint ban đầu bằng definition.initial(input), cấp ID, và dựng bản ghi trạng thái pending (transaction.ts:388–451). Chủ được kiểm lại trên bản ghi ứng viên cuối cùng lúc gom lô, nên một task không thể tạo việc con trong chính commit kết thúc nó (transaction.ts:880–902).
Niêm phong và gom lô
/** * Seal after callback success, prepare every open change, and assemble the atomic batch. * Any failure aborts every change before Storage admission. */async settleSuccess(): Promise<readonly StorageWrite[]> { this.#sealed = true; if (this.#pendingOperations.size > 0) { this.#abortChanges(); await Promise.allSettled(this.#pendingOperations); this.#context = BACKGROUND_CONTEXT; throw new Error("Session commit callback settled before its pending Tx operations"); } try { // Synchronously prepare every open change; this revokes every draft. for (const document of this.#documents) { if (document.change !== undefined) document.prepared = document.change.prepare(); } return await this.#assemble(); } catch (error) { this.#abortChanges(); throw error; } finally { this.#context = BACKGROUND_CONTEXT; }}tx còn treo (ví dụ một tx.doc() không được await), commit bị từ chối với "Session commit callback settled before its pending Tx operations". Đây là cách thực thi cạm bẫy "việc bắn rồi quên trên draft" của đặc tả (spec.md:5464–5467).for (const { id, change } of this.#submissionChanges) { const current = this.#submissions.get(id) ?? (await storage.submission(id, this.#context)); if (current === undefined) throw new Error(`Submission ${id} does not exist`); const next = applySubmissionChange(current, change); if (next !== current) this.#submissions.set(id, next);}const writes = this.#writes;for (const value of this.#submissions.values()) writes.push({ type: "submission", value });for (const task of this.#tasksById.values()) { if (task.write !== undefined) writes.push({ type: "task", value: task.write.record });}for (const plan of plans) { const change = plan.change; // Checkpoint predicates run last, after every validation. if (plan.content?.type === "document.change" && plan.content.content.kind === "delta" && change?.loaded) { const { prepared, version } = change; const info = { deltasSinceBase: change.loaded.deltasSinceBase }; if (change.definition!.checkpointWhen(prepared.value, prepared.ops, info)) { plan.content = { ...plan.content, content: { version, kind: "base", value: prepared.value } }; } } if (plan.content !== undefined) writes.push(plan.content); if (plan.retire) writes.push({ type: "document.retire", id: plan.record.id });}return writes;#assemble() lên kế hoạch document, chặn ghi vào nguồn của fork, kiểm chủ và trạng thái task, và cho mọi document của một task vừa terminal nghỉ hưu cùng nó (transaction.ts:784–848). Predicate checkpointWhen chạy cuối cùng, sau mọi kiểm tra, mỗi thay đổi đúng một lần.Vậy thứ tự trong một lô là: hội thoại và entry theo thứ tự gọi, rồi submission, rồi task, rồi lệnh document (nội dung trước, nghỉ hưu sau). Storage không được dựa vào thứ tự đó cho document: một lô có nhiều nhất một lệnh nội dung cho mỗi incarnation và nội dung luôn được áp trước nghỉ hưu, bất kể vị trí trong mảng (spec.md:5273–5278).
Bên trong document: tracker, base, delta#
Bài toán: pi.live đổi mỗi 100 ms trong lúc mô hình stream. Ghi lại cả giá trị JSON mỗi lần là tốn; chỉ ghi phần khác thì lúc đọc phải phát lại một chuỗi dài. Pi Durable giải bằng hai lớp. Trong bộ nhớ, mỗi document đã nạp có một tracker của Chord giữ revision bất biến hiện tại; một commit mở một change, sửa trên draft, rồi prepare() sinh ra giá trị mới cộng một lô phép toán (spec.md:1428–1466). Trong storage, mỗi thay đổi được lưu hoặc là một base (giá trị đầy đủ) hoặc một delta (lô phép toán), và đọc là lấy base mới nhất rồi áp các delta sau nó.
/** One committed document incarnation owned by the Session tracker cache. */export type LoadedDocument = { readonly addressId: string; readonly record: DocumentRecord; /** Persisted definition version; older while the tracked value is migrated only in memory. */ storedVersion: number; /** Definition version whose shape the tracked value has; access with another version reloads from Storage. */ readonly valueVersion: number; /** Stored deltas after the newest base; advanced by adoption so the next predicate call needs no read. */ deltasSinceBase: number; readonly tracker: Tracker<JsonObject>;};storedVersion có thể cũ hơn valueVersion khi giá trị mới chỉ được migrate trong bộ nhớ. deltasSinceBase được duy trì khi nhận commit, nên lúc đánh giá checkpointWhen không cần đọc storage.Cache này có giới hạn theo mức dùng, không theo lịch sử: một document không có ai quan sát rời bộ nhớ khi không được đọc hay ghi trong documentRetentionMs (mặc định 10 phút); document đang được quan sát thì không bao giờ rời; việc dọn chạy trên dòng commit nên không transaction nào mất tracker nó đang giữ (spec.md:1679–1695; session.ts:626–640).
Lấy draft: nạp hoặc tạo
async #acquire(entry: DocumentEntry, seed: JsonValue | undefined, skipLoad: boolean): Promise<Draft<JsonObject>> { const definition = entry.definition!; const loaded = skipLoad ? undefined : await this.#host.load(definition, entry.addressId, entry.address, this.#context); this.#assertOpen(); if (loaded !== undefined) { checkRecordScope(definition, loaded.record); checkRecordVersion(definition, loaded.record, loaded.storedVersion); entry.target = { kind: "loaded", document: loaded }; entry.change = loaded.tracker.beginChange(); return entry.change.state; } const scope = entry.address.scope; if (scope.kind === "conversation") await this.#requireConversation(scope.conversationId); if (scope.kind === "task") { const task = await this.#currentTask(scope.taskId); if (task === undefined) throw new Error(`Task ${scope.taskId} does not exist`); if (task.state.status === "terminal") throw new Error(`Task ${scope.taskId} is terminal`); } this.#assertOpen(); const value = copyJson( definition.family === true ? definition.initial(seed) : definition.initial(), ) as JsonObject; const id = await this.#host.storage.mintId<DocumentId>(); this.#assertOpen(); const tracker = track(value); entry.target = { kind: "created", record: documentCreate(definition, entry.address, id), version: definition.version, tracker, }; entry.change = tracker.beginChange(); return entry.change.state;}tx.doc() tạo document; snapshot(), documentState() và watchDoc() trả undefined khi vắng. Lần lấy đầu tiên của một địa chỉ được ghi nhớ trước khi await, nên gọi tx.doc() hai lần trong một commit trả về cùng một draft (transaction.ts:536–565). Giá trị từ initial() được sao chép vào quyền sở hữu của kernel, và bị từ chối nếu không phải JSON nghiêm ngặt.Base hay delta
case "loaded": { const loaded = target.document; const definition = document.definition!; const prepared = document.prepared!; const version = definition.version; const id = loaded.record.id; // A version change stores a base even without operations; otherwise only a change stores a delta. const content = loaded.storedVersion < version ? ({ type: "document.change", id, content: { version, kind: "base", value: prepared.value } } as const) : prepared.ops.length > 0 ? ({ type: "document.change", id, content: { version, kind: "delta", ops: prepared.ops } } as const) : undefined; const change = { tracker: loaded.tracker, prepared, version, loaded, definition, }; return { ...plan, record: loaded.record, change, ...(content === undefined ? {} : { content }) };}document.create). Một document đã có, thay đổi rỗng, thì không ghi gì và không công bố gì.Delta chưa phải là quyết định cuối: ở bước cuối của #assemble() (xem excerpt ở mục trước), mỗi delta của document đã nạp được đưa qua checkpointWhen(value, ops, { deltasSinceBase }), và true thì nó thành base mang giá trị đầy đủ. Tạo mới và chuyển version luôn là base và không gọi predicate (spec.md:1542–1562). Đặc tả cho hai mẫu chính sách:
- chặn độ dài phát lại trực tiếp:
(_v, _ops, info) => info.deltasSinceBase >= 31; - checkpoint khi trống, cho document thay đổi dày:
(value) => value.generation === undefined && value.tools === undefined(spec.md:1578–1590), đúng là ý củapi.live.
Sau khi storage ghi xong, adopt() chỉ đổi con trỏ:
const change = plan.change;if (change !== undefined) { const { tracker, prepared, loaded, version } = change; // A new incarnation is adopted unless it retires in the same commit; a loaded one only when it changed. if (loaded === undefined ? !plan.retire : prepared.ops.length > 0) tracker.adopt(prepared); else prepared.abort(); if (loaded !== undefined) { if (loaded.storedVersion < version) loaded.storedVersion = version; if (plan.content?.type === "document.change") { if (plan.content.content.kind === "base") loaded.deltasSinceBase = 0; else loaded.deltasSinceBase++; } } else if (!plan.retire) { this.#host.install({ addressId: plan.addressId, record, storedVersion: version, valueVersion: version, deltasSinceBase: 0, tracker, }); }}tracker.adopt(prepared) không diff, không áp phép toán, không cấp phát: giá trị mới đã được tính lúc prepare(), trước khi gửi xuống storage. Giá trị đó vừa là revision của tracker, vừa là giá trị được công bố (spec.md:1461–1466).latest và rewindable trong storage
Với document session, task và document hội thoại latest, một base đã commit cho phép storage vứt các bản ghi cũ hơn. Với document rewindable, base chỉ chặn độ dài phát lại, không bao giờ cho phép xoá lịch sử còn được tham chiếu (spec.md:1569–1574). Backend tham chiếu viết đúng hai dòng đó:
} else if (action.content !== undefined) { const revision = { ...action.content, seq } as DocumentRevision; if (revision.kind === "base" && isCurrentOnly(stored!.record)) stored!.revisions = [revision]; else stored!.revisions.push(revision);}if (action.retire && action.create === undefined) stored!.record = { ...stored!.record, retiredAt: seq };if (action.retire && isCurrentOnly(stored!.record)) stored!.revisions = [];DELETE FROM document_revisions (sqlite/storage.ts:922–925); JSONL viết lại file sidecar (mục JSONL).Đọc lịch sử: snapshotAsOf()
snapshotAsOf(token, conversationId, at) trả giá trị của một document rewindable như nó là tại entry at, cụ thể là giá trị cuối cùng của commit chứa entry đó:
return this.#enqueue(async () => { this.#assertHealthy(); const storedEntry = await this.#storage.entry(conversationId, at, context); if (storedEntry === undefined) { throw new Error(`Entry ${at} is not visible from conversation ${conversationId}`); } const address: DocumentAddress = { ...resolved.address, scope: { kind: "conversation", conversationId: storedEntry.entry.conversationId }, }; const record = await this.#storage.findDocument(address, storedEntry.commitSeq, context); if (record === undefined) return undefined; const stored = await this.#storage.document(record.id, storedEntry.commitSeq, context); if (stored === undefined) { throw new Error(`Historical document ${record.id} (${record.kind}) cannot be read`); } return materializeDocument(definition, stored);});private materializeDocument(id: DocumentId, at: DocumentPoint): StoredDocument | undefined { const stored = this.state.documents.get(id); if (stored === undefined) return undefined; if (at !== "current" && isCurrentOnly(stored.record)) { throw new StorageRequestError(`Document ${id} does not retain historical content`); } if (!isAliveAt(stored.record, at)) return undefined; const revisions = at === "current" ? stored.revisions : stored.revisions.filter((revision) => revision.seq <= at); let baseIndex = revisions.length - 1; while (baseIndex >= 0 && revisions[baseIndex]!.kind !== "base") baseIndex--; const base = revisions[baseIndex]; if (base?.kind !== "base") throw new Error(`Document ${id} is missing a required base`); const value = applyImmutableBatches( base.value, documentDeltaBatches(id, base.version, revisions, baseIndex + 1), ) as JsonObject; return { record: stored.record, version: base.version, value, deltasSinceBase: revisions.length - baseIndex - 1 };}createdAt <= at < retiredAt. Hỏi lịch sử của một document chỉ giữ hiện tại ném StorageRequestError, lỗi chỉ làm hỏng lần đọc đó. Thiếu base, hay delta vượt qua ranh giới version, là hỏng dữ liệu (spec.md:5259–5271).Version và migration
Migration chạy khi truy cập, qua token được truyền vào: version lưu bằng version của token thì dùng luôn; cũ hơn thì gọi migrate(value, storedVersion); mới hơn, hoặc cũ hơn mà không có migrate, thì từ chối (documents.ts:178–208). Đọc chỉ migrate trong bộ nhớ, không bao giờ ghi; lần tx.doc() đầu tiên sau đó ghi một base ở version mới, kể cả khi giá trị không đổi (loaded.storedVersion < version trong planDocument()). Lịch sử rewindable không bao giờ bị viết lại; giá trị lịch sử được migrate sau khi phát lại (spec.md:1603–1625). Cache cũng tôn trọng version: một tracker chỉ phục vụ token cùng version với giá trị của nó; token khác version (ví dụ từ code extension vừa reload) làm cache bị bỏ và nạp lại từ storage (session.ts:865–894).
Fork: thấy entry của cha mà không sao chép#
Fork một hội thoại dài phải rẻ (không chép hàng nghìn entry) và nhất quán (nhánh con thấy đúng tiền tố của cha tại điểm fork, kể cả khi cha chạy tiếp). Pi Durable không chép entry nào. Bản ghi hội thoại con chỉ mang parent: { conversationId, at }, và mọi lần quét entry đi ngược chuỗi tổ tiên với một mức trần giảm dần:
private *visibleEntries( conversationId: ConversationId, minEntryId: number = Number.NEGATIVE_INFINITY, maxEntryId: number = Number.POSITIVE_INFINITY,): Generator<EntryRecord> { if (!this.state.conversations.has(conversationId)) { throw new StorageRequestError(`Unknown conversation: ${conversationId}`); } let currentId = conversationId; let upperEntryId = maxEntryId; while (true) { const ids = this.state.entryIds.get(currentId) ?? []; for (let index = upperBound(ids, upperEntryId) - 1; index >= 0; index--) { const id = ids[index]; if (id < minEntryId) break; yield this.state.entries.get(id)!; } const conversation = this.state.conversations.get(currentId)!; if (conversation.parent === undefined) break; upperEntryId = Math.min(upperEntryId, conversation.parent.at); if (upperEntryId < minEntryId) break; currentId = conversation.parent.conversationId; }}Hai quy tắc làm cho điều này đúng. Entry bất biến, nên tiền tố của cha tới at không bao giờ đổi. Và trong một Session, ID được cấp bên trong callback, tức trên dòng commit, nên chúng tăng theo thứ tự commit: "entry của cha có ID <= at" chính xác là "entry của cha tới lúc fork". (Bản thân storage chấp nhận entry được commit lệch thứ tự ID, và bộ kiểm thử tuân thủ có một ca cho việc đó, storage-conformance.ts:251; chỉ là Session không bao giờ làm vậy.) Nhánh con gồm các entry tới E kể cả khi cùng commit đó còn append entry sau E; state document tại E là state cuối cùng của commit chứa E (spec.md:1629–1633).
Document thì khác: chúng thay đổi, nên nhánh con cần bản riêng. Chính sách fork của từng document quyết định bản đó lấy từ đâu:
fork | Giá trị của nhánh con |
|---|---|
asOf | giá trị của cha tại commit của E (cần rewindable) |
current | giá trị đã commit của cha lúc commit fork chạy |
initial | không chép gì; lần truy cập đầu ở nhánh con gọi initial() |
/** Select every persisted conversation document copied by one fork. */export async function prepareForkDocumentCopies( storage: Storage, parentConversationId: ConversationId, at: EntryId, childConversationId: ConversationId, context: Context,): Promise<readonly ForkDocumentCopy[]> { const entry = await storage.entry(parentConversationId, at, context); if (entry === undefined) throw new Error(`Entry ${at} is not visible from conversation ${parentConversationId}`); const copies: ForkDocumentCopy[] = []; const copiedAddresses = new Set<string>(); await collectCopies( storage, { kind: "conversation", conversationId: entry.entry.conversationId }, entry.commitSeq, "asOf", childConversationId, copies, copiedAddresses, context, ); await collectCopies( storage, { kind: "conversation", conversationId: parentConversationId }, "current", "current", childConversationId, copies, copiedAddresses, context, ); return copies;}collectCopies() quét các document còn sống trong phạm vi đó, giữ những cái có đúng chính sách, cấp ID mới và dựng bản ghi tạo cho nhánh con (forks.ts:61–97). Không cần định nghĩa document: document của extension không có mặt vẫn được chép nguyên version lưu (spec.md:1644–1649).Mỗi bản chép đi xuống storage dưới dạng lệnh document.copy mang bản ghi của con và một nguồn chính xác { id, at }. Storage tự hiện thực hoá nguồn và lưu thành base ban đầu, độc lập, của con, ở version lưu của nguồn (memory.ts:291–328; sqlite/storage.ts:871–890); một storage từ xa làm việc này phía server. Nếu ngay trong commit tạo fork có code truy cập document được chép, #acquireForkCopy() đọc nguồn và đổi bản chép thành một lệnh tạo bình thường (transaction.ts:649–674).
#rejectForkSourceWrites(plans: readonly DocumentPlan[]): void { for (const plan of plans) { if (plan.content === undefined && !plan.retire) continue; const record = plan.record; if (this.#forkSourceDocumentIds.has(record.id)) { throw new Error(`Cannot change fork source document ${record.id} in the fork transaction`); } const scope = plan.record.scope; if ( scope.kind === "conversation" && this.#forkSourceConversationIds.has(scope.conversationId) && record.scope.kind === "conversation" && record.fork === "current" ) { throw new Error( `Cannot fork conversation ${scope.conversationId} while changing its current-policy documents`, ); } }}current của cha, sẽ có hai nguồn "hiện tại" khác nhau; thay vì chọn ngầm một cái, transaction từ chối. Hãy commit thay đổi của cha trước (spec.md:1651–1654).Với năm document dựng sẵn, kết quả là: pi.agent (asOf) được chép; pi.live, pi.inbox, pi.usage, pi.provider (initial) được hook tạo hội thoại tạo mới; và createAgent() trả về sớm cho fork để không đè bản chép (agent.ts:174–176). Task và document của task không bao giờ được chép. Ví dụ 02-forks.ts fork tại entry đầu tiên và cho thấy nhánh con thấy "hello" mà không thấy "goodbye", với ghi chú asOf đúng như lúc fork.
Quan sát: CommittedStateSource và CommittedWatch#
Chương trước giới thiệu viewState(), watch() và quy tắc 100 frame từ phía người dùng. Bên dưới, cả view lẫn document riêng lẻ đều dùng hai lớp trong observation.ts: CommittedStateSource làm nguồn cho một state nhân bản (replicated state) của Chord, và CommittedWatch làm watch tuần tự. Cả hai được cấp revision bất biến và lô phép toán chính xác của từng commit; không ai diff lại hay sao chép.
documentState() và watchDoc() nạp document trên dòng commit, rồi gắn observer trong cùng việc đó, nên ảnh chụp ban đầu và đăng ký nhận commit sau là nguyên tử: commit nào trước lúc gắn đã nằm trong ảnh chụp, commit nào sau thì được giao (spec.md:4704–4707). Phần gắn:
const observed = { version: loaded.valueVersion };unsubscribeCommit = this.observeCommits((publication, context) => { for (const change of publication.changes) { if (change.type !== "document" || change.record.id !== loaded.record.id) continue; // A document state's frames carry no caller cancellation; a watch observes its own cancellation. const frameContext = observer instanceof CommittedStateSource ? withoutAbortSignal(context) : context; const ops = observedOperations(observed, change); // A migration-only base changes nothing for an observer of the new version. if (ops.length === 0) continue; // A retired incarnation never changes again: its observers keep their last value but pin nothing. if (change.value === null) unpin(); observer.advance(change.value, ops, frameContext); }});unsubscribeClose = this.subscribeClose(() => observer.closeSession(this.#failure));return { observer, detach };[["r", null]] rồi bỏ ghim; observer không bao giờ đi theo incarnation thay thế. Một observer dựng ở version khác nhận giá trị mới dưới dạng phép thay gốc thay vì phép toán cho hình dạng cũ (session.ts:934–942).CommittedStateSource hiện thực giao kèo nguồn của Chord: attach() trả một ảnh chụp cố định cộng con trỏ (cursor), activate(listener) cài listener duy nhất và xả mọi frame đã đệm (spec.md:4615–4650; observation.ts:25–78). Frame được giao qua microtask, không bao giờ ngay trong commit (observation.ts:103–119). Phần còn lại, gồm cursor liên tục và việc áp phép toán lên state phía client, là việc của Chord (chương Chord).
CommittedWatch thì tự giao, từng frame một, chờ listener xong mới giao frame sau:
async #drain(): Promise<void> { if (this.#running || this.#end !== undefined || !this.#started) return; this.#running = true; try { while (this.#end === undefined) { const frame = this.#pending.shift(); if (frame === undefined) break; this.#value = frame.value; const deliveryContext = withoutAbortSignal(frame.context); try { await this.#listener!(frame.value, frame.ops, deliveryContext); } catch (error) { if (this.#end === undefined) { this.#report(error); this.#terminate({ reason: "listener_error", error: toError(error) }); } break; } if (frame.value === null) { this.#terminate({ reason: "retired" }); break; } } } finally { this.#running = false; if (this.#end === undefined && this.#pending.length > 0) this.#schedule(); }}watch.value tiến tới revision của frame ngay trước khi callback được gọi. Listener ném lỗi kết thúc watch với listener_error và bỏ các frame đang chờ; frame null kết thúc nó với retired. Các lý do kết thúc khác (stopped, cancelled, session_closed, session_failed) nằm ở types.ts:1010–1015.Mount view của hội thoại dùng đúng hai lớp này (view.ts:110–140), chỉ khác là revision được dẫn xuất từ publication thay vì lấy thẳng từ một tracker. Khi Session đóng, mọi state và watch kết thúc tại lúc niêm phong: state giữ giá trị cuối, commit hoàn tất trong lúc đóng không được giao nữa (spec.md:4697–4698).
Giao diện Storage và scan#
Giao diện mà mọi backend phải hiện thực nhỏ một cách có chủ đích: một phương thức ghi, một phương thức cấp ID, các lần tra theo khoá, các lần quét theo cursor, ba phương thức cho document, và close(). "Ordered scans are cursor-based. Exact identity lookups are keyed" (spec.md:5085).
export interface Storage { /** * Atomically persist one batch and return its sequence. Once resolved, later reads through this storage observe it. */ commit(writes: readonly StorageWrite[], context: Context): Promise<Seq>; /** * Return a fresh branded candidate from the Session-global numeric ID namespace, greater than every ID minted or stored * before, also across reopen: scans order by ID as creation order, and the Harness relies on it. */ mintId<I extends Id<string>>(): Promise<I>; /** Look up one conversation by exact ID. */ conversation(id: ConversationId, context: Context): Promise<ConversationRecord | undefined>; /** Scan conversations in `query.order` (default ascending) by ID. */ scanConversations( query: ConversationQuery, limit: number, cursor: Cursor | undefined, context: Context, ): Promise<Page<ConversationRecord, Cursor>>; /** Resolve the incarnation occupying one exact logical address at the selected point. */ findDocument(address: DocumentAddress, at: DocumentPoint, context: Context): Promise<DocumentRecord | undefined>; /** Materialize one specific incarnation by ID at the selected point without following a replacement at its address. */ document(id: DocumentId, at: DocumentPoint, context: Context): Promise<StoredDocument | undefined>; /** Scan incarnations alive in one exact scope at the selected point. */ scanDocuments( query: DocumentQuery, limit: number, cursor: Cursor | undefined, context: Context, ): Promise<Page<DocumentRecord, Cursor>>;submissionByRequest() phục vụ requestId (types.ts:1201–1256). entry(conversationId, id) chỉ trả về khi entry nhìn thấy được qua tổ tiên của hội thoại, kèm commitSeq mà việc đọc lịch sử document cần./** Complete checkpoint or Chord operation batch selected by the owning Session. */export type DocumentContent = | { readonly version: number; readonly kind: "base"; readonly value: JsonObject; } | { readonly version: number; readonly kind: "delta"; readonly ops: readonly Op[]; };/** Exact persisted source selected for a definition-free document copy. */export type DocumentCopySource = { readonly id: DocumentId; readonly at: DocumentPoint;};/** Detached materialized value and stored definition version at a selected point. */export type StoredDocument = { readonly record: DocumentRecord; readonly version: number; readonly value: JsonObject; /** Deltas replayed after the selected base to materialize `value`. */ readonly deltasSinceBase: number;};DocumentContent đi vào và StoredDocument đi ra, cùng DocumentPoint là Seq | "current" (types.ts:805–821).Các truy vấn quét có bộ lọc vừa đủ cho Harness: hội thoại theo task chủ hoặc hội thoại chủ, entry theo khoảng ID, task theo hội thoại, kind, trạng thái, cờ huỷ, cờ nền, submission theo hội thoại và trạng thái (types.ts:764–803). Bộ lọc chủ phải có chỉ mục, vì việc duyệt cây sở hữu (huỷ cả cây, chờ cả cây rảnh) dựa vào chúng thay vì quét mọi hội thoại (spec.md:5243–5245). Cursor là JSON của backend, người gọi chỉ đưa lại cho đúng lần quét đó trên đúng storage đó; nó mang theo chiều quét:
export function scanStart( requested: ScanOrder | undefined, cursor: Readonly<Record<string, unknown>> | undefined, fallback: ScanOrder,): ScanStart { if (requested !== undefined && requested !== "ascending" && requested !== "descending") { throw new StorageRequestError(`Invalid scan order: ${String(requested)}`); } if (cursor === undefined) return { order: requested ?? fallback, after: undefined }; const { after, order: stored } = cursor; if (typeof after !== "number" || !Number.isSafeInteger(after)) throw new StorageRequestError("Invalid storage cursor"); if (stored !== undefined && stored !== "ascending" && stored !== "descending") { throw new StorageRequestError("Invalid storage cursor"); } const order = stored ?? fallback; if (requested !== undefined && requested !== order) { throw new StorageRequestError(`The cursor continues a ${order} scan; the query asks for ${requested}`); } return { order, after };}/** The continuation of a scan whose last returned item has `id`. */export function nextCursor(id: Id<string>, order: ScanOrder): { readonly after: number; readonly order: ScanOrder } { return { after: id, order };}StorageRequestError: lỗi của một lần đọc không hợp lệ, không có hậu quả bền, chỉ làm hỏng lần đọc đó (mục Lỗi). Quy tắc chiều quét được thêm ở 1.1.0 như một thay đổi phá vỡ cho backend tự viết (CHANGELOG.md:103).Ngoài các chữ ký, hợp đồng còn đòi vài điều backend phải tự kiểm khi commit: một ID chỉ thuộc một bảng; hội thoại, entry và document chỉ được tạo một lần; mỗi địa chỉ logic có nhiều nhất một incarnation hiện tại; delta không được vượt ranh giới version (memory.ts:733–815). Và mọi lần đọc trả về giá trị đã tách: chuỗi bất biến được dùng chung, mảng và object thì không (spec.md:1736–1743).
MemoryStorage: ngữ nghĩa tham chiếu#
Năm backend đi kèm (README.md:761–767):
| Backend | Mở bằng | Ghi chú |
|---|---|---|
| Memory | new MemoryStorage() (gốc package) | Không lưu gì; ngữ nghĩa tham chiếu |
| SQLite | openNodeSqliteStorage(file) | Một file; WAL với synchronous = NORMAL |
| JSONL | openNodeJsonlStorage(dir, context) | File chỉ-append trong một thư mục; giữ toàn bộ lịch sử trong bộ nhớ |
| IndexedDB | await IndexedDbStorage.open(name) | Chỉ trình duyệt |
| Durable Object | openDurableObjectSqliteStorage(ctx.storage) | SQLite của một Durable Object; commit là transaction của nó |
MemoryStorage là ngữ nghĩa tham chiếu: nó sao chép giá trị ghi được giữ lại và mọi kết quả đọc, cố ý mô phỏng ranh giới sở hữu mà mã hoá/giải mã của SQLite và JSONL tự nhiên tạo ra; đây là để tuân thủ, không phải để phòng thủ (spec.md:5383–5387). Cách nó đạt tính nguyên tử là mẫu đáng học:
async commit(writes: readonly StorageWrite[], _context: Context): Promise<Seq> { return this.prepareCommit(writes).apply();}/** Validate and detach one commit without changing observable state. */prepareCommit(writes: readonly StorageWrite[], seq: Seq = seqFromNumber(this.nextSeq)): PreparedMemoryCommit { this.assertOpen(); if (!Number.isSafeInteger(seq) || seq < this.nextSeq) { throw new Error(`Commit sequence ${seq} does not strictly increase`); } const detachedWrites = freeze(this.resolveDocumentCopies(writes.map((write) => clone(write)))); this.checkGlobalIds(detachedWrites); const documentActions = this.prepareDocumentActions(detachedWrites); this.checkDocumentActions(documentActions); let applied = false; return { seq, writes: detachedWrites, apply: () => { if (!applied) { applied = true; this.applyPreparedCommit(detachedWrites, documentActions, seq); } return seq; }, };}prepareCommit() sao chép và đóng băng lô, giải các lệnh document.copy, kiểm quyền sở hữu ID và lệnh document, mà không chạm state. apply() chỉ còn việc cập nhật map và chỉ mục, không có bước nào có thể thất bại giữa chừng. Cách chia hai bước này còn được JSONL dùng lại: nó chuẩn bị bằng MemoryStorage, ghi file, rồi mới apply().State bên trong là các map theo ID cộng các mảng ID đã sắp xếp làm chỉ mục: theo hội thoại, theo chủ, theo trạng thái, theo request ID, theo địa chỉ và phạm vi document (memory.ts:72–92). ID bắt đầu từ 2, Seq từ 1 (memory.ts:259–260).
Lược đồ SQLite#
Tên bảng và chỉ mục là một lịch sử bất biến: không bao giờ đổi hay xoá giá trị đã có, đổi tên thì thêm mục mới, để migration cũ vẫn render đúng tên gốc (schema.ts:5–38). DDL của schema version 1 nằm trong createSqliteMigrations():
const initialSchema: readonly string[] = [ `CREATE TABLE ${tables.durableMetadata} ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), next_id TEXT NOT NULL, next_seq INTEGER NOT NULL ) STRICT`, `INSERT INTO ${tables.durableMetadata} (singleton, next_id, next_seq) VALUES (1, '2', 1)`, `CREATE TABLE ${tables.recordIds} ( id INTEGER PRIMARY KEY, record_type TEXT NOT NULL CHECK (record_type IN ('conversation', 'entry', 'task', 'submission', 'document')) ) STRICT`, `CREATE TABLE ${tables.conversations} ( id INTEGER PRIMARY KEY, owner_conversation_id INTEGER, owner_task_id INTEGER, record TEXT NOT NULL CHECK (json_valid(record)) ) STRICT`, `CREATE INDEX ${indexes.conversationsByOwnerConversation} ON ${tables.conversations} (owner_conversation_id, id)`, `CREATE INDEX ${indexes.conversationsByOwnerTask} ON ${tables.conversations} (owner_task_id, id)`, `CREATE TABLE ${tables.entries} ( id INTEGER PRIMARY KEY, conversation_id INTEGER NOT NULL, head INTEGER, commit_seq INTEGER NOT NULL, record TEXT NOT NULL CHECK (json_valid(record)) ) STRICT`, `CREATE INDEX ${indexes.entriesByConversation} ON ${tables.entries} (conversation_id, id DESC)`, `CREATE INDEX ${indexes.entryHeadsByConversation} ON ${tables.entries} (conversation_id, id DESC) WHERE head IS NOT NULL`,next_id là TEXT vì node:sqlite từ chối số nguyên vượt khoảng an toàn của JavaScript (chú thích ở dòng 13). record_ids hiện thực "một không gian ID toàn cục": mỗi ID ghi nhận bảng sở hữu nó. Chỉ mục một phần trên head IS NOT NULL giúp tìm head marker mới nhất bằng một lần tra.`CREATE TABLE ${tables.tasks} ( id INTEGER PRIMARY KEY, conversation_id INTEGER NOT NULL, kind TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'waiting', 'completing', 'terminal')), abort_requested INTEGER NOT NULL CHECK (abort_requested IN (0, 1)), background INTEGER NOT NULL CHECK (background IN (0, 1)), record TEXT NOT NULL CHECK (json_valid(record))) STRICT`,`CREATE INDEX ${indexes.tasksByStatus} ON ${tables.tasks} (status, id)`,`CREATE INDEX ${indexes.tasksByConversation} ON ${tables.tasks} (conversation_id, id)`,`CREATE INDEX ${indexes.tasksByKind} ON ${tables.tasks} (kind, id)`,`CREATE INDEX ${indexes.tasksByAbortRequested} ON ${tables.tasks} (abort_requested, id)`,`CREATE INDEX ${indexes.tasksByBackground} ON ${tables.tasks} (background, id)`,`CREATE TABLE ${tables.submissions} ( id INTEGER PRIMARY KEY, conversation_id INTEGER NOT NULL, request_id TEXT, status TEXT NOT NULL CHECK (status IN ('queued', 'placed', 'done', 'unanswered')), record TEXT NOT NULL CHECK (json_valid(record))) STRICT`,`CREATE INDEX ${indexes.submissionsByRequest} ON ${tables.submissions} (conversation_id, request_id)`,`CREATE INDEX ${indexes.submissionsByConversation} ON ${tables.submissions} (conversation_id, id)`,`CREATE INDEX ${indexes.submissionsByStatus} ON ${tables.submissions} (status, id)`,record dạng JSON, có CHECK (json_valid(record)). Ràng buộc trạng thái của version 1 chưa có sleeping.`CREATE TABLE ${tables.documents} ( id INTEGER PRIMARY KEY, kind TEXT NOT NULL, family INTEGER NOT NULL CHECK (family IN (0, 1)), key_value TEXT NOT NULL, scope_kind TEXT NOT NULL CHECK (scope_kind IN ('session', 'conversation', 'task')), owner_id INTEGER NOT NULL, created_at INTEGER NOT NULL, retired_at INTEGER, record TEXT NOT NULL CHECK (json_valid(record))) STRICT`,`CREATE INDEX ${indexes.documentsByAddress} ON ${tables.documents} (kind, scope_kind, owner_id, family, key_value, created_at DESC, retired_at)`,`CREATE INDEX ${indexes.documentsByScope} ON ${tables.documents} (scope_kind, owner_id, id)`,`CREATE INDEX ${indexes.documentsByScopeKind} ON ${tables.documents} (scope_kind, owner_id, kind, id)`,`CREATE TABLE ${tables.documentRevisions} ( document_id INTEGER NOT NULL, seq INTEGER NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('base', 'delta')), version INTEGER NOT NULL, content TEXT NOT NULL CHECK (json_valid(content)), PRIMARY KEY (document_id, seq)) STRICT`,`CREATE INDEX ${indexes.documentRevisionsByKind} ON ${tables.documentRevisions} (document_id, kind, seq DESC)`,documents giữ một hàng cho mỗi incarnation với các cột địa chỉ (kind, family, key, phạm vi, chủ) và vòng đời (created_at, retired_at là số Seq). document_revisions giữ base và delta theo (document_id, seq); chỉ mục (document_id, kind, seq DESC) tìm base mới nhất trước một seq.Schema version 2 dựng lại bảng tasks để thêm trạng thái sleeping, vì SQLite không đổi được ràng buộc CHECK tại chỗ (migrations.ts:91–116). Bộ chạy migration chung nằm trong Chord và chạy mọi migration còn thiếu trong một transaction, ghi version vào bảng durable_schema, và từ chối một database đã được migrate bởi bản mới hơn:
await database.transaction(async (transaction) => { await transaction.exec(`CREATE TABLE IF NOT EXISTS ${table} ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), version INTEGER NOT NULL CHECK (version >= 0) ) STRICT`); await transaction.run(`INSERT OR IGNORE INTO ${table} (singleton, version) VALUES (1, 0)`); const row = await transaction.get<SchemaRow>(`SELECT version FROM ${table} WHERE singleton = 1`); if (row === undefined) throw new Error("SQLite schema metadata is missing"); const currentVersion = migrations.at(-1)?.version ?? 0; if (row.version > currentVersion) { throw new Error(`SQLite schema version ${row.version} is newer than supported version ${currentVersion}`); } for (const migration of migrations) { if (migration.version <= row.version) continue; for (const statement of migration.statements) await transaction.exec(statement); await transaction.run(`UPDATE ${table} SET version = ? WHERE singleton = 1`, migration.version); }});SQLite: commit, adapter và prefix#
Một transaction SQL là một commit của Session (spec.md:5391):
async commit(writes: readonly StorageWrite[], _context: Context): Promise<Seq> { this.assertOpen(); const documentActions = this.prepareDocumentActions(writes); const candidateNextId = this.candidateNextId(writes); const seq = await this.db.transaction(async (transaction) => { const metadata = await transaction.get<MetadataRow>( `SELECT next_id, next_seq FROM ${this.schema.tables.durableMetadata} WHERE singleton = 1`, ); if (metadata === undefined) throw new Error("Durable SQLite metadata is missing"); const committedSeq = seqFromNumber(metadata.next_seq); await this.checkGlobalIds(transaction, writes); await this.checkDocumentActions(transaction, documentActions); for (const write of writes) await this.applyTableWrite(transaction, write, committedSeq); await this.applyDocumentActions(transaction, documentActions, committedSeq); await transaction.run( `UPDATE ${this.schema.tables.durableMetadata} SET next_id = ?, next_seq = ? WHERE singleton = 1`, String(Math.max(Number(metadata.next_id), candidateNextId)), committedSeq + 1, ); return committedSeq; }); this.nextId = Math.max(this.nextId, candidateNextId); return seq;}Seq là next_seq đọc trong chính transaction, rồi tăng một. Kiểm quyền sở hữu ID và lệnh document chạy trước mọi lần ghi; một lỗi bất kỳ làm cả transaction rollback, nên chỉ mục và bảng không bao giờ lệch nhau (ca tuân thủ "rolls back record tables and secondary indexes when a document command fails", storage-conformance.ts:1478).Từng loại ghi đi vào bảng của nó (sqlite/storage.ts:795–854): hội thoại và entry là INSERT thuần (ghi trùng thì lỗi khoá chính, đúng tính bất biến), task và submission là INSERT ... ON CONFLICT(id) DO UPDATE vì chúng là bản ghi thay thế trọn. Mọi ID đi qua claimId() vào record_ids. Các chuỗi dùng làm khoá chỉ mục (kind, request_id, key_value) được lưu dưới dạng JSON string, vì một số binding SQLite thay thế ký tự surrogate lẻ, và mã hoá JSON giữ danh tính không mất mát (sqlite/storage.ts:74–75). Đọc một document gói hai truy vấn (bản ghi, rồi base và đuôi delta) trong một transaction để chúng thấy cùng một trạng thái (sqlite/storage.ts:549–553), và việc ghi revision cho document current-only xoá các revision cũ khi gặp base (sqlite/storage.ts:922–950).
Lõi khả chuyển và adapter
SqliteStorage không gọi API của Node. Nó nói chuyện với một facade bất đồng bộ SqliteDatabase của Chord gồm exec, run, get, all, transaction, close; callback của transaction nhận một handle riêng, mọi việc trong transaction phải dùng handle đó, và adapter phải xếp hàng mọi thao tác khác cho tới khi transaction xong (chord/src/sqlite/database.ts:18–38; README.md:792–799). Hai adapter đi kèm:
- Node:
openNodeSqliteStorage(path, options)mở file bằngnode:sqlitequaopenNodeSqliteDatabase()của Chord (sqlite/node.ts:14–22), vốn bậtjournal_mode = WAL,synchronous = NORMALvà auto-checkpoint WAL mỗi 1.000 trang (chord/src/sqlite/node.ts:179–192). README nói rõ hệ quả: commit sống qua crash của tiến trình, nhưng commit mới nhất có thể mất khi mất điện hay hỏng máy chủ (README.md:765). - Cloudflare Durable Object:
openDurableObjectSqliteStorage(ctx.storage, options), xem mục Cloudflare bên dưới.
Prefix: chung database với bảng của host
Từ 1.3.0, { prefix: "pi_" } đặt tiền tố cho mọi bảng và chỉ mục, để Pi chung một database với bảng của ứng dụng (CHANGELOG.md:48). Storage tự render tên đã có tiền tố vào SQL của nó, nên adapter không cần hỗ trợ gì (schema.ts:53–61). Tiền tố phải là một định danh SQLite, không được bắt đầu bằng sqlite_ (chord/src/sqlite/schema.ts:1–9); host còn có thể giữ thêm tiền tố riêng, như _cf_ trong Durable Object; đổi tiền tố không migrate dữ liệu cũ (README.md:773–777).
JSONL#
Backend JSONL giữ dữ liệu trong các file chỉ-append trong một thư mục, dùng khả năng FileSystem khả chuyển từ /env chứ không phải toàn bộ ExecutionEnv (spec.md:5410–5421):
main.jsonl: ghi bảng, bản ghi document, và đúng một marker cho mỗi commit;doc-<id>.jsonl: nội dung (base, delta) của một incarnation document;task-<id>.jsonl: các bản thay thế của một task còn sống.
type MainOperation = | Extract<StorageWrite, { readonly type: "conversation" | "entry" | "submission" | "document.retire" }> | { readonly type: "task"; readonly value: StoredTask } | { readonly type: "task.sidecar"; readonly id: TaskId; readonly ordinal: number } | { readonly type: "document.create"; readonly record: DocumentCreate; readonly ordinal: number } | { readonly type: "document.change"; readonly id: DocumentId; readonly ordinal: number };type MainMarker = { readonly format: typeof FORMAT_VERSION; readonly type: "commit"; readonly seq: Seq; readonly writes: readonly MainOperation[];};type SidecarPayload = | { readonly type: "task"; readonly value: StoredTask } | { readonly type: "document"; readonly id: DocumentId; readonly content: DocumentContent };type SidecarRecord = { readonly format: typeof FORMAT_VERSION; readonly type: "record"; readonly seq: Seq; readonly ordinal: number; readonly payload: SidecarPayload;};ordinal của bản ghi sidecar trong commit đó (jsonl/storage.ts:384–449).Giao thức công bố có ba bước: append mọi bản ghi sidecar, append một marker hoàn chỉnh liệt kê chúng, và chỉ công bố trong bộ nhớ sau khi marker được ghi thành công (spec.md:5423–5429). Code theo đúng thứ tự đó:
async commit(writes: readonly StorageWrite[], context: Context): Promise<Seq> { this.assertUsable(); const prepared = this.memory.prepareCommit(writes); const encoded = this.encodeCommit(prepared.seq, prepared.writes); const reclamations = this.planReclamations(prepared.writes, encoded); const sidecars = await Promise.all( [...encoded.sidecars].map(async ([file, content]) => ({ file, content, path: await this.resolveFile(file, context), })), ); for (const sidecar of sidecars) { const result = await this.fs.appendFile(sidecar.path, sidecar.content, context); if (!result.ok) throw this.poison(errorFromFile(`append to ${sidecar.file}`, result.error)); } if (this.fsync) { for (const sidecar of sidecars) { const result = await this.fs.flushFile(sidecar.path, context); if (!result.ok) throw this.poison(errorFromFile(`flush of ${sidecar.file}`, result.error)); } } const marker = await this.fs.appendFile(this.mainPath, encoded.marker, context); if (!marker.ok) throw this.poison(errorFromFile(`append to ${MAIN_FILE}`, marker.error)); const seq = prepared.apply(); this.adoptSidecarState(prepared.writes); await this.reclaimSidecars(reclamations, context); return seq;}MemoryStorage nghĩa là mọi kiểm tra ngữ nghĩa xảy ra trước khi chạm đĩa. Một lần append thất bại thì không biết đã ghi được bao nhiêu, nên storage bị đầu độc (poisoned): mọi lời gọi sau ném JsonlStoragePoisonedError cho tới khi mở lại (jsonl/storage.ts:837–845). Trên Session, lỗi đó lại làm Session thất bại như mọi lỗi storage khác.fsync và độ bền
fsync mặc định là false (jsonl/storage.ts:77–80). Không có nó, JSONL bảo đảm nhất quán khi tiến trình crash, không bảo đảm sống qua mất điện hay lỗi kernel. Với { fsync: true }, mọi sidecar bị ảnh hưởng được flush trước khi append marker, để một marker sống sót không bao giờ đi trước dữ liệu sidecar của nó; main.jsonl không bị flush ở mỗi commit, nên commit cuối đã được xác nhận vẫn có thể mất (spec.md:5430–5441).
Thu hồi và khôi phục
Sau khi marker đã công bố state, JSONL dọn dẹp như một việc bảo trì có thể làm lại: một base của document current-only viết lại sidecar của nó chỉ còn base đó; nghỉ hưu một document current-only hay kết thúc một task thì xoá sidecar. Việc viết lại đi qua một file tạm .reclaim rồi đổi tên (jsonl/storage.ts:451–533). Khi mở, recover() đọc lại mọi thứ (jsonl/storage.ts:535–765):
- Cắt dòng ráchByte cuối file không phải xuống dòng nghĩa là lần ghi cuối bị cắt giữa chừng; file bị cắt về dòng hoàn chỉnh cuối cùng (jsonl/storage.ts:793–804).
- Kiểm marker
seqtrongmain.jsonlphải tăng nghiêm ngặt; dòng hoàn chỉnh mà hỏng là lỗiJsonlCorruptionError, không phải dữ liệu bị bỏ qua. - Xác nhận sidecarMỗi bản ghi sidecar chỉ có hiệu lực khi một marker tham chiếu nó; đuôi chưa được xác nhận bị cắt bỏ; thiếu dữ liệu đã được xác nhận là hỏng dữ liệu và việc mở thất bại (jsonl/storage.ts:767–784).
- Phát lại vào bộ nhớMỗi marker được áp vào
MemoryStoragenội bộ với đúngseqcủa nó, rồi thu hồi những gì còn dở.
Adapter Node truyền một NodeExecutionEnv làm FileSystem (jsonl/node.ts:5–12); lõi /storage/jsonl chạy được ở nơi khác (Bun, Durable Object) với một FileSystem khác (README.md:790).
IndexedDB#
Backend mới nhất (thêm ở 1.4.0, CHANGELOG.md:16) chạy trong trình duyệt. Nó dựng các object store tương ứng một-một với bảng SQLite, kể cả record_ids và meta giữ nextId, nextSeq, với chỉ mục ghép cùng hình dạng (indexeddb.ts:335–375).
async commit(writes: readonly StorageWrite[], _context: Context): Promise<Seq> { this.assertOpen(); const documentActions = this.prepareDocumentActions(writes); return this.transaction(ALL_STORES, "readwrite", async (transaction) => { const metaStore = transaction.objectStore(STORES.meta); const metadata = await requestResult(metaStore.get(META_KEY) as IDBRequest<MetaRow>); if (metadata === undefined) throw new Error("Durable IndexedDB metadata is missing"); const seq = seqFromNumber(metadata.nextSeq); await this.checkGlobalIds(transaction, writes); await this.checkDocumentActions(transaction, documentActions); for (const write of writes) await this.applyTableWrite(transaction, write, seq); await this.applyDocumentActions(transaction, documentActions, seq); let nextId = metadata.nextId; for (const write of writes) { const id = writeId(write); if (id !== undefined) nextId = Math.max(nextId, id + 1); } await requestResult(metaStore.put({ key: META_KEY, nextId, nextSeq: seq + 1 } satisfies MetaRow)); return seq; });}async mintId<I extends Id<string>>(): Promise<I> { this.assertOpen(); return this.transaction(STORES.meta, "readwrite", async (transaction) => { const store = transaction.objectStore(STORES.meta); const metadata = await requestResult(store.get(META_KEY) as IDBRequest<MetaRow>); if (metadata === undefined) throw new Error("Durable IndexedDB metadata is missing"); if (!Number.isSafeInteger(metadata.nextId)) throw new Error("ID space is exhausted"); await requestResult(store.put({ ...metadata, nextId: metadata.nextId + 1 })); return idFromNumber<I>(metadata.nextId); });}mintId() ở đây tự commit bộ đếm trong một transaction riêng, nên ID đã cấp không bao giờ được cấp lại kể cả khi transaction của Session bị huỷ.Một chủ duy nhất phải giữ database tại mỗi thời điểm; README khuyên bọc vòng đời của nó trong một Web Lock thay vì mở ở nhiều tab hay worker cạnh tranh, và gọi navigator.storage.persist() khi cần giữ dữ liệu lâu (README.md:769–771). Ghi chú của lớp nói đúng điều đó: "One owner must serialize access to each database" (indexeddb.ts:377).
Bộ kiểm thử tuân thủ và benchmark#
Một backend tự viết không cần đọc hết đặc tả để biết mình đúng: /testing xuất một bộ kiểm thử tuân thủ (conformance suite) độc lập với test runner.
/** Registers the runner-independent cases with a Vitest/Jest-compatible test runner. */export function registerStorageConformance( runner: StorageConformanceRunner, name: string, withStorage: StorageConformanceProvider,): void { const cases = createStorageConformance({ assertions: createExpectAssertions(runner.expect), withStorage, }); runner.describe(name, () => { for (const testCase of cases) runner.it(testCase.name, testCase.run); });}withStorage phải gọi và chờ callback đúng một lần cho mỗi ca, ví dụ mở storage, chạy, rồi đóng trong finally (README.md:801–815).Bộ kiểm thử có 25 ca, mỗi ca là một hợp đồng cụ thể, ví dụ ca đầu tiên kiểm ID 1 dành cho hội thoại gốc và việc tạo lại nó bị từ chối (storage-conformance.ts:98–105). Các ca khác phủ commit nguyên tử và rollback, giá trị đã tách, quét fork sâu qua mọi mức trần, quét hai chiều với cursor, document rewindable và incarnation nửa mở, bản chép document độc lập, base khi chuyển version, vòng đời create-plus-retire, chuỗi đặc biệt trong khoá chỉ mục, và từ chối mọi thao tác sau close(). Trong repo, bộ này chạy trên Memory, SQLite, JSONL, IndexedDB (qua fake-indexeddb), các biến thể "across reopen" của ba backend lưu bền, adapter Durable Object có prefix trên một storage giả lập bằng node:sqlite, và adapter đó trên Durable Object thật trong workerd (memory-storage.test.ts:8, sqlite-storage.test.ts:113–117, jsonl-storage.test.ts:123–125, indexeddb-storage.test.ts:106–108, sqlite-cloudflare.test.ts:10, test-workers/storage.test.ts:13; cấu hình workerd ở test-workers/vitest.config.ts:7). Câu "The semantic conformance suite covers memory, SQLite, and JSONL" của đặc tả (spec.md:5280) đã cũ so với code: IndexedDB và Durable Object cũng được phủ.
Cùng chỗ đó có bộ dữ liệu và kịch bản benchmark dùng lại được (storage-benchmark.ts:24–34): đọc (tra entry chính xác, quét trang 100 entry, quét task có lọc, tra địa chỉ document giữa nhiều document, đọc lịch sử trước và sau một base mới hơn, quét và tra head qua 8 tầng fork; storage-benchmark.ts:284) và ghi (commit một entry, 100 entry, một lô trộn entry/task/submission/document; :409). npm run bench:storage chạy chúng trên memory, SQLite và JSONL bằng vitest bench, kèm đo thời gian mở lại (storage.bench.ts:18; package.json:124); bench:storage:memory đo bộ nhớ ở quy mô 1k và 10k bản ghi.
Lỗi: ba quy tắc và code thực thi chúng#
README tóm cách xử lý lỗi bằng ba quy tắc, để host biết chuyện gì xảy ra mà không phải đọc code (README.md:821–825; bản đầy đủ ở spec.md:5282–5377). Phần này nối từng quy tắc với code thực thi nó.
/** A transaction read a table after its first table write. Read every required row before writing. */export class ReadAfterWrite extends Error { constructor(method: string) { super(`Tx.${method}() cannot read tables after the first table write`); this.name = "ReadAfterWrite"; }}/** * A Storage read rejected an invalid request, such as an unknown conversation or a cursor from another scan, with no * durable effect. Unlike any other error a Storage throws, it fails only that read, not the Session; from `commit()` it is * fatal like any other. */export class StorageRequestError extends Error { constructor(message: string) { super(message); this.name = "StorageRequestError"; }}/** * The Session failed, so nothing runs or commits any more. It fails when a Storage method throws, a commit cannot be * adopted, or the Harness's own bookkeeping throws (a scheduler commit, an internal commit listener, a registry whose * `snapshot()` throws). `cause` is that first error. Reopen the Session; reopening recovers from what was committed. */export class SessionFailed extends Error { constructor(cause: unknown) { super("Session failed after a storage error; close and reopen it", { cause }); this.name = "SessionFailed"; }}Quy tắc 1: lỗi storage kết thúc Harness
Mọi thành phần của Session và Harness đọc ghi qua GuardedStorage, một lớp bọc quanh storage thật. Mọi lời gọi đi qua một hàm duy nhất:
/** * Run one backend call under the guard. `read` is a read's context; `commit()` and `mintId()` pass none, so nothing * exempts their errors: once a batch is admitted, whether it committed is unknown. A call still underway when another * fails the Session ends with `SessionFailed` too, whatever it returns: nothing it read or wrote is used. */async #call<T>(read: Context | undefined, run: () => Promise<T>): Promise<T> { this.#assertHealthy(); if (this.#closing) throw new Error("Session is closed"); let result: T; // An async wrapper, so a backend that throws synchronously is guarded too. const call = (async () => run())(); this.#underway.add(call); try { result = await call; } catch (error) { this.#assertHealthy(); // An invalid read, or one its caller cancelled, says nothing about the Storage; it fails that call only. const exempt = read !== undefined && (error instanceof StorageRequestError || read.abortSignal?.aborted === true); if (!exempt) this.#latch.fail(error); throw error; } finally { this.#underway.delete(call); } this.#assertHealthy(); return result;}SessionFailed, bất kể nó trả về gì, vì không thứ gì nó đọc hay ghi được dùng nữa./** * Internal: fail the Session with `error`, the first wins, and report it once. Admission ends, and close listeners run * now; they read the error from `failure`. The Storage guard calls this; the Harness also calls it for a throw in its * scheduler's own commits. */fail(error: unknown, context?: Context): void { if (this.#failure !== undefined) return; this.#failure = { error }; this.#failed.reject(new SessionFailed(error)); // Seal first, so a report handler that calls back finds the Session failed. Closing runs the close listeners // synchronously; a failing backend close is the caller's to see, and here nobody waits. this.close(BACKGROUND_CONTEXT).catch(() => {}); this.report(error, { type: "session_failed" }, context);}onReport gọi ngược lại vào Harness thấy nó đã thất bại.Từ lúc đó: lời gọi gặp lỗi nhận chính lỗi đó; mọi lời gọi sau và mọi lần chờ đang treo nhận SessionFailed với cause là lỗi đầu (util.ts:57–61); watch và luồng sự kiện kết thúc với session_failed (observation.ts:219–224); task đang chạy nhận tín hiệu huỷ; onReport nhận lỗi một lần với type: "session_failed"; Session tự đóng và closed trả { reason: "failed", error }. Không có gì được thử lại; storage tự thử lại lỗi tạm thời bên trong phương thức của nó. Phục hồi là mở lại: commit nguyên tử nên storage nhất quán, và gửi lại với cùng requestId tìm thấy submission đã được nhận nếu commit kịp thành công. Host khởi động lại theo mẫu:
`harness.closed` settles once the Harness has closed, by `close()` or after a storage error, so a host restarts on failure like this:```typescriptharness.closed.then((end) => { if (end.reason === "failed") reopen(end.error);});```Quy tắc này có từ 1.2.0, khi lớp lỗi StorageRejected cũ bị bỏ và mọi lỗi storage, kể cả lỗi đọc, trở thành lỗi chết (CHANGELOG.md:66). Lý do: một lỗi đọc giữa chừng một quyết định của scheduler để bộ nhớ ở trạng thái không biết đúng hay sai; dừng hẳn và dựng lại từ storage đơn giản và an toàn hơn đoán.
Quy tắc 2: lần đọc không hợp lệ chỉ hỏng lần đọc đó
Biến exempt trong #call() là toàn bộ quy tắc: chỉ lần đọc (có read context) được miễn, và chỉ khi lỗi là StorageRequestError hoặc người gọi đã tự huỷ. commit() và mintId() không truyền context đọc nên không bao giờ được miễn: một khi lô đã được nhận, không biết nó đã commit hay chưa (session.ts:973–980). Bản 1.4.0 sửa chính README cho khớp điểm này (CHANGELOG.md:41). Backend ném StorageRequestError cho cursor hỏng (scan.ts:25–26), hội thoại không tồn tại, hay lịch sử mà document không giữ (memory.ts:662–664). Một callback commit đã bắt một lần đọc thất bại thì không commit được nữa, vì #runCommit() kiểm sức khoẻ Session sau callback.
Quy tắc 3: code của bạn chỉ hỏng đơn vị của nó
| Code ném lỗi | Hậu quả | Ở đâu |
|---|---|---|
Callback commit, conversationCreated, init, initial, migrate, checkpointWhen của document | Commit đó rollback và reject | session.ts:729–733 |
| Phase của task | Task kết thúc faulted, báo task_faulted | chương task |
execute() của tool, hoặc dựng môi trường thất bại | Mô hình nhận kết quả lỗi | chương task |
| Hook | Được báo và bỏ qua; riêng beforeTool ném lỗi thì chặn lời gọi | tool.ts:154–157 |
| Section của prompt | Giữ chữ đang hiển thị, báo, vẫn gửi request | prompt.ts:37–43 |
| Listener của host (commit, close, watch, subscriber) | Được báo, các listener khác vẫn chạy; watch kết thúc listener_error | session.ts:916–928 |
Listener nội bộ của Session, hoặc snapshot() của RegistryReader | Làm Session thất bại như lỗi storage, vì bộ nhớ hoặc khả năng lập lịch đã mất | spec.md:5325–5333 |
onReport | Bị bỏ, vì không còn chỗ nào để báo | harness.ts:443–447 |
Mọi báo cáo tới onReport(error, info) kèm info.type nói chuyện gì đã xảy ra (session_failed, task_faulted, task_parked, migration_failed, task_outdated, task_error, extension_error, host_error, listener_error), để host phân biệt một Session hỏng (thường do nền tảng reset) với một task lỗi (README.md:218).
Cloudflare Durable Object#
Agent phần lớn thời gian ngồi chờ giữa các request, và "Keeping a machine running around the clock wastes compute and quickly gets expensive", như bài "Pi Durable on Cloudflare" (2026-10-16) mở đầu. Durable Object của Cloudflare ngủ đông (hibernate) khi rảnh và thức dậy khi có request, tin nhắn WebSocket hay alarm. Bài viết dựng một agent chat nhiều người dùng: mỗi session là một Durable Object có SQLite, định địa chỉ bằng session ID, vì một Harness gắn với đúng một storage. Phần này đi qua phía storage và mẫu host; cách task ngủ và lập lịch nằm ở chương task.
Adapter: commit là transaction của Durable Object
/** * Open durable storage on a SQLite-backed Durable Object, optionally under a table prefix. Calls given the same * storage object share an operation queue. */export function openDurableObjectSqliteStorage( storage: DurableObjectSqliteStorage, options: SqliteStorageOptions = {},): Promise<SqliteStorage> { return SqliteStorage.open(new DurableObjectSqliteDatabase(storage), options);}const storageQueues = new WeakMap<DurableObjectSqliteStorage, SerialQueue>();function queueFor(storage: DurableObjectSqliteStorage): SerialQueue { let queue = storageQueues.get(storage); if (queue === undefined) { queue = new SerialQueue(); storageQueues.set(storage, queue); } return queue;} /** `ctx.storage.transaction()` commits when the callback resolves and rolls back when it rejects. */ transaction<T>(callback: (transaction: SqliteExecutor) => Promise<T>): Promise<T> { return this.queue.hold(() => this.storage.transaction(async () => { const transaction = new DurableObjectSqliteTransaction(this.storage); try { return await callback(transaction); } finally { transaction.active = false; } }), ); }ctx.storage.sql của Cloudflare là đồng bộ; adapter bọc nó thành facade bất đồng bộ của Chord và xếp hàng các lời gọi phía sau một transaction đang chạy. Hàng đợi được dùng chung qua một WeakMap theo storage object, nên nhiều storage Pi (với prefix khác nhau) trên cùng Durable Object tự phối hợp với nhau (CHANGELOG.md:52). Số nguyên được bind như double, nên một bigint ngoài khoảng an toàn bị từ chối thay vì mất chính xác (chord/src/sqlite/cloudflare.ts:67–75).Muốn dùng chung database với bảng của host, hãy tạo một adapter và dùng nó cho cả Pi lẫn mọi truy vấn của host; gọi thẳng ctx.storage sẽ đi vòng qua hàng đợi và có thể bị rollback cùng một transaction của Pi (README.md:779–789).
Mẫu host: blockConcurrencyWhile, onWake và alarm
Một Durable Object có thể bị trục xuất (evict) bất cứ lúc nào, và trong lúc ngủ không có timer nào chạy. Vì vậy Harness không tự giữ timer cho task đang ngủ khi host truyền onWake; nó báo cho host biết khi nào Session cần một tiến trình:
/** * When a Session next needs a running process (`Harness.wake()`). `active`: some task can run now or is running, or a * reconcile pass such as open's recovery has not landed yet, so a process that dies must be woken again soon, for * example by a recovery alarm. `at`: the earliest sleep not yet due, * when there is one. Neither: nothing needs a process until new work is submitted. Tasks blocked on a missing or * unmigratable definition, `completing` tasks, and waits on live tasks do not count; the work they wait for does. */export type Wake = { readonly active: boolean; readonly at?: number };completing, và việc chờ trên task còn sống không được tính; công việc mà chúng chờ thì được tính.Worker dùng cho test của package hiện thực đúng mẫu mà README mô tả (README.md:166–210):
constructor(ctx: DurableObjectState, env: unknown) { super(ctx, env); this.#state = ctx; void ctx.blockConcurrencyWhile(async () => { // A fallback before anything can throw: an object whose open or recovery fails is started again. const fallback = Date.now() + HEARTBEAT_MS; const current = await ctx.storage.getAlarm(); if (current === null || current > fallback) await ctx.storage.setAlarm(fallback); this.#offset = ctx.storage.kv.get<number>("offset") ?? 0; const faux = fauxProvider(); faux.setResponses(Array.from({ length: 8 }, () => this.#step())); const models = createModels(); models.setProvider(faux.provider); const registry = createRegistry(); registry.install(defineExtension({ name: "test", tasks: [Reminder] })); this.#harness = await Harness.open( await openDurableObjectSqliteStorage(ctx.storage), { models, registry, now: () => this.now(), onWake: (wake) => { this.wakes.push(wake); // A failed alarm write resets the object; it arms again when it next starts. void this.arm(wake).catch(() => ctx.abort("Alarm write failed")); }, }, context, ); // Armed before `resume()` runs anything, as for any call that can start work. await this.arm(this.#harness.wake()); this.#harness.resume(); });}blockConcurrencyWhile bắt mọi sự kiện chờ tới khi khởi tạo xong; Cloudflare chạy constructor khi tạo lần đầu và lại chạy sau mỗi lần ngủ đông hay bị trục xuất. Alarm dự phòng được đặt trước mọi thứ có thể ném lỗi, để một object mở hỏng vẫn được khởi động lại. Ghi alarm thất bại thì ctx.abort() reset object; lần khởi động sau sẽ arm lại.async alarm(): Promise<void> { await this.arm({ ...this.#harness.wake(), active: true }); this.#harness.resume();}/** The alarm a host arms for `wake`: the earliest sleep, or a recovery heartbeat while work can run. */async arm(wake: Wake): Promise<void> { const heartbeat = wake.active ? Date.now() + HEARTBEAT_MS : undefined; const sleep = wake.at === undefined ? undefined : wake.at - this.#offset; const at = heartbeat === undefined || sleep === undefined ? (heartbeat ?? sleep) : Math.min(heartbeat, sleep); if (at === undefined) await this.#state.storage.deleteAlarm(); else await this.#state.storage.setAlarm(at);}async root(): Promise<Conversation> { return this.#harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } });}async submit(text: string, requestId: string): Promise<void> { // Arm before admitting work: a process that dies after the commit is still woken. await this.arm({ ...this.#harness.wake(), active: true }); await (await this.root()).submit({ type: "input", content: text, requestId }, context);}onWake chạy sau commit; một alarm sớm là vô hại, một alarm thiếu thì bỏ rơi công việc (README.md:195–196). Alarm của Durable Object chạy ít nhất một lần, nên handler phải idempotent; bài blog nhấn mạnh đúng điều này.Trong lúc có việc, arm() đặt một nhịp tim (heartbeat) 60 giây, không bao giờ muộn hơn giấc ngủ sớm nhất: nếu object bị trục xuất giữa chừng, alarm đánh thức nó, Harness.open() đưa task đang running về pending, và resume() chạy tiếp. Ở đây requestId cũng là thứ cho exactly-once: bài blog lưu một biên nhận cùng commit tạo hội thoại, và client gửi lại các yêu cầu chưa được xác nhận với cùng request ID.
Timer và bộ nhớ trong Workers
/** * Cloudflare Workers: a pending timer keeps a Durable Object from hibernating or being evicted, even an unreferenced * one (`nodejs_compat` gives timers an `unref()` that changes nothing). */const inWorkers = typeof navigator !== "undefined" && navigator.userAgent === "Cloudflare-Workers";/** * The default `HarnessOptions.setTimer`: `setTimeout`, waiting out delays beyond its limit in steps. A background timer * is unreferenced, so it never keeps the process alive. It is declined in Cloudflare Workers and wherever timers cannot * be unreferenced. */export const nativeTimer: SetTimer = (callback, delayMs, options) => { if (options.background && inWorkers) return undefined;unref(). Timer nền chỉ để giải phóng bộ nhớ, nên chúng bị từ chối; Session khi đó dọn document rảnh ở cuối mỗi commit thay vì theo timer (session.ts:674–684). Đây là một bản sửa của 1.4.0 (CHANGELOG.md:40).Giới hạn bộ nhớ 128 MB của một Durable Object cũng để lại dấu vết trong CHANGELOG: bộ mã hoá base64 dùng cho ảnh từng tốn khoảng 140 MB heap cho một ảnh 3 MiB và được viết lại ở 1.3.0 (CHANGELOG.md:57). Các test của test-workers/ chạy trong tích hợp Vitest của Cloudflare Workers, gồm cả bộ kiểm thử tuân thủ trên một Durable Object có prefix. Bài blog dẫn tới repo mẫu earendil-works/pi-durable-cloudflare, với một subagent tìm kiếm là một hội thoại do task tool sở hữu, khai báo replay-safe để khi object bị trục xuất giữa lời gọi thì chạy lại và dùng lại đúng hội thoại con cũ.
Ví dụ: một commit ghi những dòng nào#
Ghép mọi thứ lại với commit C1 của trace ở chương trước: người dùng gửi một input tới hội thoại gốc đang rảnh, inbox rỗng, không có requestId. Lần theo code:
- Callback
admitSubmission()lấy draftpi.live(document 2) và, quaprepareBoundary(), đọc head marker (bảng) rồi lấy draftpi.inbox(document 3) mà không sửa. RồiappendInput()cấp entry 7,createSubmission()cấp submission 8,startRun()cấp task 9 và gánlive.run. - Niêm phongDraft của document 2 sinh một phép set lên
["run"]; draft của document 3 không có phép nào nên không ghi gì.checkpointWhencủapi.livetrảtrue(không có generation, không tool nào chạy), nên thay đổi được ghi thành base. - Lô
[entry 7, submission 8, task 9, document.change 2 (base)], đúng thứ tự entry, submission, task, document.
Trên SQLite, SqliteStorage.commit() chạy các câu lệnh sau trong một transaction:
Ví dụ minh hoạ: storage mới; commit 1 là root() (hội thoại 1, document 2–6), đây là commit 2.
Câu lệnh theo đúng thứ tự trong storage.ts; JSON rút gọn bằng "...".
SELECT next_id, next_seq FROM durable_metadata WHERE singleton = 1 → ('7', 2)
SELECT record_type FROM record_ids WHERE id = ? (7, 8, 9: chưa thuộc bảng nào)
SELECT record FROM documents WHERE id = ? (2: có, chưa nghỉ hưu)
SELECT id FROM documents WHERE kind = ? AND scope_kind = ? ... AND retired_at IS NULL
INSERT OR IGNORE INTO record_ids (id, record_type) VALUES (7, 'entry')
INSERT INTO entries (id, conversation_id, head, commit_seq, record)
VALUES (7, 1, NULL, 2,
'{"model":[{"role":"user","content":"Đọc notes.txt rồi tóm tắt","timestamp":1761104000000}],
"kind":"pi.user","id":7,"conversationId":1}')
INSERT OR IGNORE INTO record_ids (id, record_type) VALUES (8, 'submission')
INSERT INTO submissions (id, conversation_id, request_id, status, record)
VALUES (8, 1, NULL, 'placed',
'{"conversationId":1,"type":"input","status":"placed","entry":7,"id":8}') ON CONFLICT(id) DO UPDATE ...
INSERT OR IGNORE INTO record_ids (id, record_type) VALUES (9, 'task')
INSERT INTO tasks (id, conversation_id, kind, status, abort_requested, background, record)
VALUES (9, 1, '"pi.generation"', 'pending', 0, 0,
'{"id":9,"conversationId":1,"kind":"pi.generation","version":1,"input":{},
"background":false,"abortRequested":false,
"state":{"status":"pending","checkpoint":{"phase":"prepare","attempt":1}}}') ON CONFLICT(id) DO UPDATE ...
SELECT record FROM documents WHERE id = ? (2)
DELETE FROM document_revisions WHERE document_id = ? (2: pi.live là latest, base cũ bị bỏ)
INSERT INTO document_revisions (document_id, seq, kind, version, content)
VALUES (2, 2, 'base', 1, '{"run":{"taskId":9,"inputs":[8]}}')
UPDATE durable_metadata SET next_id = '10', next_seq = 3 WHERE singleton = 1
documents không đổi: hàng của document 2 vẫn là (2, '"pi.live"', 0, '""', 'conversation', 1, 1, NULL, ...). Cột kind của task và key_value của document là chuỗi JSON (có ngoặc kép), vì encodeIndexedString(). Trường requestId, trace, head, byTaskId vắng mặt vì copyJson bỏ giá trị undefined.Trên JSONL, cùng lô đó thành ba dòng mới trong ba file, theo đúng giao thức sidecar trước, marker sau:
Ví dụ minh hoạ: cùng commit 2 trên JSONL (mỗi bản ghi là một dòng; ở đây xuống dòng cho dễ đọc).
1. append task-9.jsonl (task còn sống → sidecar, ordinal 0)
{"format":1,"type":"record","seq":2,"ordinal":0,"payload":{"type":"task","value":{"id":9,
"conversationId":1,"kind":"pi.generation","version":1,"input":{},"background":false,
"abortRequested":false,"state":{"status":"pending","checkpoint":{"phase":"prepare","attempt":1}}}}}
2. append doc-2.jsonl (nội dung document → sidecar, ordinal 1)
{"format":1,"type":"record","seq":2,"ordinal":1,"payload":{"type":"document","id":2,
"content":{"version":1,"kind":"base","value":{"run":{"taskId":9,"inputs":[8]}}}}}
với { fsync: true }: flush task-9.jsonl và doc-2.jsonl ở đây
3. append main.jsonl (marker: entry và submission nằm thẳng trong đó)
{"format":1,"type":"commit","seq":2,"writes":[
{"type":"entry","value":{"model":[...],"kind":"pi.user","id":7,"conversationId":1}},
{"type":"submission","value":{"conversationId":1,"type":"input","status":"placed","entry":7,"id":8}},
{"type":"task.sidecar","id":9,"ordinal":0},
{"type":"document.change","id":2,"ordinal":1}]}
4. áp vào MemoryStorage nội bộ (lúc này commit mới được công bố)
5. thu hồi: doc-2.jsonl được viết lại chỉ còn dòng của bước 2
(ghi doc-2.jsonl.reclaim rồi đổi tên), vì pi.live là latest và vừa có base mới
requestId. Nếu chết sau bước 3, commit đã bền; bước 5 chỉ là dọn dẹp và sẽ được làm lại lúc khôi phục.Sau khi storage trả về Seq 2, adopt() đổi con trỏ tracker của document 2, và publication { seq: 2, changes: [entry 7, submission 8, task 9, document 2] } đến các listener: scheduler thấy task 9 pending và xếp lịch; mount view của hội thoại 1 (nếu có người xem) thêm một phép splice vào entries và một phép set dưới ["docs", "pi.live", "run"]; watchEvents() phát message_start, message_end, submission, run_start, turn_start. Không ai trong số đó thấy gì trước khi transaction SQL ở trên commit, hay trước khi marker nằm trong main.jsonl.
Quyết định thiết kế#
Một dòng commit cho mỗi Session, mở rộng bằng nhiều Session
Không có đồng thời bên trong một Session: commit, đọc nhiều bước và dọn cache xếp hàng trên một chuỗi promise. Nhờ đó các bất biến dễ giữ và dễ kiểm: ID tăng theo thứ tự commit, đọc-trước-ghi-sau có nghĩa, publication không bao giờ trộn hai commit, và backend không cần mutex riêng. Cái giá là một callback chậm chặn cả Session, nên đặc tả cấm mọi hiệu ứng ngoài bên trong transaction (spec.md:1697–1701). Khi cần mở rộng, mô hình là thêm Session, như một Durable Object cho mỗi session trong bài blog Cloudflare, không phải thêm luồng vào một Session.
Storage ngây thơ, Session thông minh
Storage tin Session về ngữ nghĩa và chỉ thực thi những gì cấu trúc dữ liệu đòi hỏi: nguyên tử, sở hữu ID, tạo-một-lần, một incarnation hiện tại cho mỗi địa chỉ, không có delta vượt version. Nó không chạy code định nghĩa nào: "Storage executes no definition code and never receives an unused complete candidate with a selected delta" (spec.md:1564–1567). Hệ quả là mỗi backend nhỏ (memory.ts 864 dòng, SQLite 957, JSONL 846, IndexedDB 1.208) và kiểm được bằng cùng một bộ tuân thủ; một backend mới là hiện thực mười sáu phương thức.
Bảng là bản ghi, document là cây Chord
Hội thoại, entry, task và submission là bản ghi phẳng: tạo một lần hoặc thay trọn, với vài cột được tách ra để làm chỉ mục và phần còn lại là JSON. Document là cây JSON thay đổi từng chút, nên được lưu bằng phép toán Chord, có lịch sử khi cần. Pi Durable không dịch phép toán Chord sang hàm JSON của SQL: SQLite lưu thẳng các bản ghi Chord (spec.md:5400–5402), và đó là một mục trong danh sách không làm (spec.md:5615). Đánh đổi: không truy vấn được nội dung document bằng SQL, đổi lại một đường ghi duy nhất cho mọi backend.
Lỗi storage là lỗi chết
Trước 1.2.0, một số lỗi storage có thể được bỏ qua. Từ 1.2.0, mọi lỗi (trừ hai ngoại lệ của lần đọc) làm Session thất bại và tự đóng; không có gì được thử lại. Lựa chọn này đổi một chút sẵn sàng lấy sự đơn giản của tính đúng: bộ nhớ không bao giờ phải đoán storage đang ở trạng thái nào, vì trạng thái đúng duy nhất là thứ đọc lại được sau khi mở lại, và commit nguyên tử bảo đảm nó nhất quán.
Entry chia sẻ, document sao chép
Fork không chép entry vì entry bất biến: một mức trần ID trên chuỗi tổ tiên là đủ. Document thì thay đổi, nên được chép (phía server, bằng lệnh document.copy) theo chính sách mà định nghĩa của nó khai báo. Đặc tả nhấn mạnh current, initial và asOf là ngữ nghĩa sản phẩm chứ không phải tối ưu hoá (spec.md:5537–5538).
Độ bền được nói rõ cho từng backend
Không backend nào giả vờ bền hơn thực tế: SQLite chạy WAL với synchronous = NORMAL (sống qua crash tiến trình, có thể mất commit cuối khi mất điện); JSONL mặc định chỉ nhất quán qua crash tiến trình, fsync là tuỳ chọn; Durable Object dùng transaction của chính nền tảng. Ứng dụng chọn mức bền bằng cách chọn backend và tuỳ chọn, không phải bằng một cờ chung.
Cạm bẫy và trường hợp biên#
- Transaction dài và đọc trong commit. Await mô hình, mạng hay một commit lồng bên trong callback chặn cả Session; gọi
snapshot()haygetTask()bên trong có thể treo vĩnh viễn khi trượt cache (spec.md:5521–5528). - Việc bắn rồi quên trên draft. Một
tx.doc()không được await làm commit bị từ chối lúc niêm phong; sửa draft sau khi callback xong thì ném lỗi. - Đọc bảng sau khi ghi. Đọc mọi hàng cần thiết trước lần ghi bảng đầu tiên; hàm cần đọc bảng (như
prepareBoundary()) phải chạy đầu commit. - Fork trong lúc sửa cha. Một commit vừa fork vừa sửa document
currentcủa cha bị từ chối; commit thay đổi của cha trước. - Không bao giờ checkpoint.
checkpointWhen: () => falsehợp với document ít đổi hay sống ngắn (như document của task); với document đổi dày nó làm chuỗi phát lại và dung lượng lưu của document current-only tăng không giới hạn khi document còn sống (spec.md:5534–5536). - Đổi tên kind. Kind và đường mount là giao thức đã lưu; migration không đổi được kind, phải chép tường minh rồi cho nghỉ hưu (spec.md:5539–5543).
- Sửa revision bất biến. Giá trị từ
snapshot(), state và watch có thể dùng chung object với tracker; không có đóng băng lúc chạy, nên sửa chúng làm hỏng cache một cách lặng lẽ. Cần sửa thì sao chép trước (spec.md:5544–5546). - Hai tiến trình, một storage. Không có khoá liên tiến trình; với IndexedDB, hai tab cùng mở một database là sai. Một Harness mới chỉ được mở sau khi
close()của Harness cũ đã xong (spec.md:3820–3823). - Không quay lui được qua schema SQLite version 2. 1.4.0 tự migrate; 1.3.0 trở về trước từ chối database đó. Đổi prefix cũng không migrate dữ liệu cũ.
- JSONL cho agent chạy lâu. Bộ nhớ lớn theo lịch sử,
main.jsonlkhông được nén, và không cófsyncthì commit đã xác nhận có thể mất khi mất điện (spec.md:5600–5602). - Kết quả lớn trong bản ghi task. Bản ghi task terminal còn được giữ để tra; kết quả lớn nên nằm trong entry hay document sống lâu, outcome chỉ giữ ID (spec.md:5560–5562).
- Gọi thẳng
ctx.storagetrên Durable Object. Bỏ qua hàng đợi của adapter và có thể bị rollback cùng một transaction của Pi; dùng chung mộtDurableObjectSqliteDatabase.
Đọc code ở đâu#
Mọi đường dẫn tính từ packages/durable/, trừ khi ghi khác.
| # | File | Đọc để hiểu |
|---|---|---|
| 1 | docs/spec.md §3–4, §10–11 | Document, transaction, hợp đồng storage, lỗi, backend |
| 2 | src/types.ts | Storage, StorageWrite, DocumentRecord, DocumentContent, Tx, Session |
| 3 | src/session/session.ts | #enqueue, #runCommit, #publish, cache document, snapshotAsOf, GuardedStorage |
| 4 | src/session/transaction.ts | Quy tắc bảng, appendEntry, createTask, doc/#acquire, settleSuccess, #assemble, adopt, planDocument |
| 5 | src/documents.ts, src/ids.ts | Địa chỉ document, kiểm version, migration; nhãn ID |
| 6 | src/session/forks.ts, observation.ts | Chép document khi fork; nguồn state và watch |
| 7 | src/storage/scan.ts, memory.ts | Cursor; backend tham chiếu (đọc prepareCommit, visibleEntries, materializeDocument) |
| 8 | sqlite/migrations.ts, sqlite/storage.ts, chord/src/sqlite/ | Lược đồ, commit, đọc lịch sử; facade và adapter |
| 9 | jsonl/storage.ts | Sidecar, marker, fsync, thu hồi, khôi phục |
| 10 | indexeddb.ts | Cùng hợp đồng trên object store |
| 11 | src/errors.ts | Ba lớp lỗi; đọc cùng GuardedStorage |
| 12 | testing/storage-conformance.ts, storage-benchmark.ts | Hợp đồng dưới dạng 25 ca; kịch bản benchmark |
| 13 | test-workers/worker.ts, src/timers.ts | Mẫu host Durable Object, timer trong Workers |
| 14 | test/examples/00 tới 05, 13, 17 | Session trần, document, fork, state, watch; khôi phục trên SQLite; JSONL để mở ra xem |
Tóm tắt#
- Session là một dòng commit (một chuỗi promise) cộng một cache tracker document; Harness kế thừa nó.
- Một commit đi qua bảy bước: callback, niêm phong và chuẩn bị, gom lô,
storage.commit(), đổi con trỏ tracker, publication, thả dòng; không gì được công bố trước khi storage trả về. - Transaction gom entry và hội thoại theo thứ tự gọi, task và submission theo bản ứng viên cuối, document theo kế hoạch; đọc bảng phải trước lần ghi bảng đầu tiên.
- Document lưu base hoặc delta theo
checkpointWhencủa định nghĩa; latest được thu hồi, rewindable giữ lịch sử chosnapshotAsOf(); migration chạy lúc truy cập. - Fork chia sẻ entry qua mức trần trên chuỗi tổ tiên và chép document theo chính sách
asOf,current,initial. - Năm backend (Memory, SQLite, JSONL, IndexedDB, Durable Object) hiện thực cùng giao diện nhỏ và cùng bộ kiểm thử tuân thủ 25 ca.
- Lỗi storage làm Session thất bại và tự đóng; lần đọc không hợp lệ chỉ hỏng chính nó; code của người dùng chỉ hỏng đơn vị của nó. Phục hồi là mở lại.
19Durable: task, scheduler, generation và tool#
Chương này đi vào phần chạy việc của Pi Durable: task là gì, scheduler chạy nó ra sao, và ba task dựng sẵn (generation, tool, compaction) biến một input thành câu trả lời theo cách sống sót được khi tiến trình chết. Trung tâm của chương là một lần theo dấu: tiến trình bị SIGKILL giữa một lời gọi tool, rồi một tiến trình mới mở lại storage và gọi resume().
Vai trò: mọi thứ harness chạy là một task#
Trong coding agent, vòng lặp agent sống trong bộ nhớ: lượt nào đang chạy, tool nào đang chờ, mô hình đã trả về được bao nhiêu chữ, tất cả là biến của một tiến trình (chương pi-agent-core). Tiến trình chết thì phần đó mất; file session chỉ giữ những message đã xong. Pi Durable muốn điều ngược lại: tiến trình bị giết giữa chừng, tiến trình sau mở lại cùng storage và làm tiếp đúng bước đang dở. Muốn vậy, "đang làm dở cái gì" phải là dữ liệu đã commit chứ không phải biến.
Câu trả lời là task: một máy trạng thái bền (durable state machine) lưu checkpoint ở mỗi bước, nên một tiến trình khởi động lại sẽ tiếp tục từ checkpoint cuối (README.md:99). Bài công bố "Pi Durable" gói ý này trong một câu: "everything the harness runs is a task". Một câu trả lời không phải một vòng lặp mà là một chuỗi task dựng sẵn: pi.generation gọi mô hình, sở hữu các task pi.tool ứng với từng lời gọi tool, chờ chúng xong rồi trao lần chạy (run) cho generation kế tiếp; pi.compaction tóm tắt transcript khi cửa sổ ngữ cảnh (context window) sắp đầy (spec.md:3945–3951).
Task đến từ ba nguồn. Harness tạo task dựng sẵn khi xử lý submission. Extension mang định nghĩa task riêng trong trường tasks. Host hoặc tool tạo task trong một commit bằng tx.createTask(). Chỉ một thành phần chạy chúng: TaskScheduler trong packages/durable/src/harness/scheduler.ts. Scheduler nằm giữa hai chương lân cận: bên dưới là Session, dòng commit nguyên tử và storage (chương Durable: session, commit, document và storage); bên trên là Harness, conversation, submission và extension (chương Pi Durable: mô hình khái niệm). Chương này không giải thích lại hai tầng đó mà chỉ đường sang khi cần.
/** Built-in task definitions every registry holds; they are not an extension and cannot be removed or replaced. */export const BUILTIN_TASKS: readonly AnyTask[] = [GenerationTask, ToolTask, CompactionTask]; constructor(extensions: readonly Extension<Tool>[]) { this.#extensions = extensions; this.#byName = new Map(extensions.map((extension) => [extension.name, extension])); const tasks = new Map<string, AnyTask>(); for (const task of BUILTIN_TASKS) tasks.set(task.definition.name, task); for (const extension of extensions) { for (const task of extension.tasks ?? []) { const name = task.definition.name; if (tasks.has(name)) throw new Error(`Task ${name} of extension ${extension.name} is already installed`); tasks.set(name, task); } } this.#tasks = tasks;Harness.open() từ chối registry thiếu một trong ba task này (harness.ts:546–551). Task của extension được tra theo tên lưu trong bản ghi, nên tên task là một phần của định dạng lưu trữ.Mô hình tư duy: phase, checkpoint và commit#
Hãy hình dung một task như một hàm dài được cắt thành nhiều đoạn; mỗi đoạn là một phase. Giữa hai đoạn, mọi thứ đoạn sau cần được ghi vào một checkpoint JSON và commit. Tiến trình chết giữa một phase thì phase đó chạy lại từ đầu với checkpoint cuối đã commit. Không gì khác sống sót: biến cục bộ, promise, timer đều mất. Vì thế mọi thiết kế trong chương quy về một câu hỏi: trước khi làm việc có hậu quả bên ngoài (gọi API, chạy lệnh), task đã ghi lại những gì?
tx.createTask() trong một commit
│
▼
┌───────────► pending ◄─────────── open(): running → pending, restarts + 1
│ │ close(): running → pending, không đếm
│ handover │ reservation (commit của scheduler)
│ ▼
└──────────── running ── phase handler gọi runtime.commit(...) ──┐
│ │ │ │
│ │ └─► running, checkpoint mới ── phase kế, cùng invocation
│ ├───► waiting { on, policy } ── mọi task trong on xong ──► running
│ └───► sleeping { until } ─── đồng hồ tới until ────────► running
│
└─► kết quả ── còn việc sở hữu đang sống ──► completing ──┐
│ ▼
└───────────────────────────────────────────► terminal
completed · failed · aborted · orphaned · faulted
waiting, sleeping và kết quả là do code của task commit qua runtime.commit(); reservation, đối soát lúc mở và đóng, handover, completing → terminal, fault và orphan là do scheduler ghi.Từ vựng dùng trong phần còn lại của chương:
| Từ | Nghĩa | Ở đâu |
|---|---|---|
| definition | Đối tượng TaskDefinition: tên, version, initial(), bảng phase, abort(), migrate(). Sống trong registry, không được lưu. | types.ts:334 |
| record | TaskRecord: bản ghi JSON trong storage, được thay nguyên khối ở mỗi commit. | types.ts:690 |
| invocation | Một lần chạy trong bộ nhớ của task, ở chế độ run hoặc abort, với AbortController riêng. Không bền. | scheduler.ts:105 |
| reservation | Commit của scheduler đổi pending/waiting/sleeping thành running và tạo invocation. | scheduler.ts:1466 |
| step | Quyết định đồng bộ trên dòng commit (mutation line) của Session trước mỗi phase: chạy tiếp, dừng, hay ghi faulted. | scheduler.ts:1886 |
| memo | Giá trị JSON nhỏ trong record, người ghi đầu tiên thắng; sống qua checkpoint, mất khi task kết thúc. | spec.md:2053 |
| owner, việc sở hữu thường | Mỗi task thuộc về conversation của nó hoặc một task khác. "Việc sở hữu thường" (ordinary owned work) là các task sống bên dưới, không tính nhánh background. | spec.md:2316 |
| ý định huỷ (cancellation intent) | Abort mark của một owner còn sống, hoặc kết quả đang giữ khác completed. Nó lan xuống việc sở hữu thường. | scheduler.ts:2398 |
| blocked, parked | Task không chạy được: thiếu hoặc lệch definition, migration hỏng, hoặc bị đỗ vì crash quá nhiều lần. Lý do được suy ra, không lưu. | scheduler.ts:448 |
Spec gọi khuôn mẫu cốt lõi là "bánh mì kẹp hiệu ứng" (effect sandwich): commit ý định, làm hiệu ứng bên ngoài, commit kết quả. Mở lại khi đang ở phase ý định nghĩa là hiệu ứng có thể đã xảy ra, và phase phải tự chọn: thử lại an toàn, hỏi trạng thái bên ngoài, hoặc ghi nhận là bị gián đoạn. Ba lựa chọn này sẽ xuất hiện lại nguyên văn trong task pi.tool.
### 5.2 Effect sandwich```textcommit intent phaseperform external effectcommit outcome or next phase```Reopening in an intent phase means the effect may have happened. The phasehandler retries safely, polls an external handle, or records interruption.Deferred providers are represented by a durable phase containing their handleand next poll time.poll.Data model: TaskRecord, TaskState, TaskOutcome#
Ba kiểu trong packages/durable/src/types.ts mô tả toàn bộ trạng thái bền của một task. Bắt đầu từ kết quả, vì năm loại kết thúc nói nhiều về triết lý thiết kế:
/** Durable reason and optional result recorded when a task becomes terminal. */export type TaskOutcome<R> = | { readonly status: "completed"; readonly result: R; readonly error?: never; readonly reason?: never; } /** Expected task or domain failure explicitly committed by its implementation. */ | { readonly status: "failed"; readonly error: TaskOutcomeError; readonly result?: R; readonly reason?: never; } /** Explicit cancellation handled by the task's abort protocol. */ | { readonly status: "aborted"; readonly reason?: string; readonly result?: R; readonly error?: never; } /** Task that cannot resume because its definition or migration is unavailable. */ | { readonly status: "orphaned"; readonly reason: string; readonly result?: never; readonly error?: never; } /** Runtime-detected contract failure, such as an uncaught throw or no durable progress. */ | { readonly status: "faulted"; readonly error: TaskOutcomeError; readonly result?: never; readonly reason?: never; };failed và aborted có thể mang result: một tool bị gián đoạn kết thúc failed nhưng vẫn kèm ID của entry kết quả. orphaned và faulted không bao giờ có result, vì code của task không quyết định chúng.completed,failed: do chính task commit.failedlà thất bại nghiệp vụ có chủ đích (mô hình trả lỗi, thẻ bị từ chối).aborted: handlerabort()của task đã chạy và tự quyết định kết quả, tức là task đã có cơ hội dọn dẹp.orphaned: task bị huỷ nhưng không có code nào chạy được (definition thiếu hoặc không nhận bản ghi), nên hiệu ứng bên ngoài có thể còn sót.reasonlà lý do bị chặn (spec.md:2229–2234).faulted: runtime phát hiện vi phạm hợp đồng: phase ném lỗi, hoặc phase trả về mà không tiến bộ bền (scheduler.ts:1826–1832).
/** Complete durable execution state of a task. */export type TaskState<S, R> = | { /** Eligible for scheduling. */ readonly status: "pending"; /** Complete durable state from which execution resumes. */ readonly checkpoint: S; readonly outcome?: never; } | { /** Reserved by one in-memory task invocation. */ readonly status: "running"; /** Complete durable state from which execution resumes. */ readonly checkpoint: S; readonly outcome?: never; } | { /** Parked without an invocation until every task in `on` is terminal; then resumes at `checkpoint`. */ readonly status: "waiting"; readonly checkpoint: S; readonly on: readonly TaskId[]; readonly policy: JoinPolicy; readonly outcome?: never; } | { /** * Parked without an invocation until the Harness clock reaches `until`, in milliseconds on the * `HarnessOptions.now` clock; then resumes at `checkpoint`. An abort mark does not wait for it. */ readonly status: "sleeping"; readonly checkpoint: S; readonly until: number; readonly outcome?: never; } | { /** Outcome decided; becomes terminal once no ordinary owned work below is live. Runs no more code. */ readonly status: "completing"; readonly checkpoint?: never; readonly outcome: TaskOutcome<R>; } | { /** Permanently settled durable result receipt. */ readonly status: "terminal"; readonly checkpoint?: never; readonly outcome: TaskOutcome<R>; };checkpoint; hai trạng thái cuối mang outcome và không còn checkpoint. sleeping mới có từ 1.4.0; until là mili giây trên đồng hồ HarnessOptions.now.Bản ghi bọc trạng thái trong các trường định danh và lập lịch. Hai nhóm trường đáng chú ý: nhóm bất biến (conversationId, owner, background, abandonOnRestart) dựng nên cây sở hữu, và nhóm phục hồi (abortRequested, abortReason, restarts) cho scheduler biết phải làm gì khi mở lại.
type TaskRecordBase<I, R> = { /** Adapter-owned context that created this task; phase invocations extract it as their parent. */ readonly trace?: TelemetryCarrier; /** Immutable creation-time links for the task's first invocation. */ readonly links?: readonly TelemetryCarrier[]; readonly id: TaskId<R>; readonly conversationId: ConversationId; /** Registered task definition name. */ readonly kind: string; /** Definition version used to migrate live input and checkpoints. */ readonly version: number; /** Original task input retained while the task is live or terminal. */ readonly input: I; /** Owning task of a child task; absent for a task its conversation owns. Immutable. */ readonly owner?: TaskId; /** Whether this conversation-owned task is excluded from ordinary idle waits, conversation aborts, and cascades. */ readonly background: boolean; /** Durable abort mark checked before run-mode progress is committed. */ readonly abortRequested: boolean; /** * Why the mark was set when not by an abort request: `restart` for a task abandoned after a restart * (`TaskOptions.abandonOnRestart`) or below one. Such a task waits for its definition instead of becoming `orphaned`. * A later abort request clears it. */ readonly abortReason?: "restart"; /** Set from `TaskOptions.abandonOnRestart`. Immutable. */ readonly abandonOnRestart?: true; /** * Wall-clock milliseconds of the first change to `running`, stamped by the Session. Kept through waits and recovery, * so the span to `endedAt` includes them. Absent before the task first runs, and on records written by earlier * versions. */ readonly startedAt?: number; /** Wall-clock milliseconds of the change to `terminal`, stamped by the Session; absent while live. */ readonly endedAt?: number; /** * How many times in a row a crash, eviction, or reset cut the task off while it ran, without the task changing its * state since; open counts them, and a close does not. At `HarnessSettings.restartLimit` the task is parked as blocked * until `Harness.retryTask()`. */ readonly restarts?: number;};startedAt/endedAt là thời điểm vòng đời do Session đóng dấu, không phải thời gian thực thi. restarts đếm số lần liên tiếp một crash cắt ngang task mà task chưa đổi trạng thái./** Complete replacement record for one durable task state machine. */export type TaskRecord<I, S, R> = TaskRecordBase<I, R> & ( | { readonly state: Extract< TaskState<S, R>, { readonly status: "pending" | "running" | "waiting" | "sleeping" } >; /** Small first-writer-wins values retained while the task can run. */ readonly memos?: Readonly<Record<string, JsonValue>>; } | { readonly state: Extract<TaskState<S, R>, { readonly status: "completing" | "terminal" }>; readonly memos?: never; } );memos biến mất khi task đã có kết quả; scheduler làm điều đó trong withState() (scheduler.ts:2473–2478).Ví dụ minh hoạ dưới đây là bản ghi của một lời gọi bash mà tiến trình trước bị giết sau khi đã ghi ý định. Đây là thứ tiến trình mới thấy trong storage sau khi open() chạy xong (các số ID là bịa, cấu trúc theo đúng kiểu ở trên):
Ví dụ minh hoạ
{
"id": 57, "conversationId": 1, "kind": "pi.tool", "version": 2,
"input": { "kind": "model", "assistant": 55, "callId": "call_b" },
"owner": 54, // task pi.generation đã tạo lời gọi này
"background": false, "abortRequested": false,
"startedAt": 1761120000000, "restarts": 1,
"state": {
"status": "pending", // open() vừa hạ running xuống pending
"checkpoint": { "phase": "execute",
"arguments": { "command": "npm test" }, "replay": "unsafe" }
}
}
memos: tool chưa ghi memo nào. Không có kết quả: bản ghi vẫn sống.Mỗi checkpoint là một bản thay thế đầy đủ, không phải bản vá (spec.md:1927–1928). Tx công khai cũng không có thao tác ghi đè task: task chỉ đổi trạng thái của chính nó qua runtime, còn mọi chuyển trạng thái khác (reservation, đối soát khi mở lại, handover, fault, orphan) thuộc về scheduler, và abort mark thuộc về abortTask() (spec.md:1945–1948).
Định nghĩa một task: defineTask và TaskDefinition#
defineTask() chỉ là một hàm đồng nhất (identity) để TypeScript suy ra kiểu; nó gói definition vào một đối tượng Task. Hàm này nằm trong src/tasks.ts, không phải src/harness/define.ts (file kia chứa defineTool, hook, section, wrapTool).
/** Define an executable task. Register it in the registry so a Harness can run tasks of its kind. */export function defineTask<I, S extends { phase: string }, R, H extends object = object>( definition: TaskDefinition<I, S, R, H>,): Task<I, S, R, H> { return { definition };}Bốn tham số kiểu: I là input (bất biến, lưu trong bản ghi), S là kiểu hợp các checkpoint, mỗi nhánh có trường phân biệt phase, R là kết quả, H là giao diện hook mà task cho extension cắm vào.
/** Executable durable state machine definition, registered in the registry by `name`. */export type TaskDefinition<I, S extends { phase: string }, R, H extends object> = { /** Registered task kind persisted in `TaskRecord.kind`. */ readonly name: string; /** Definition version persisted with live input and checkpoints. */ readonly version: number; /** First durable checkpoint for a newly created task. */ initial(input: I): S; /** Exhaustive phase map; each handler receives the task narrowed to its phase. */ readonly phases: { readonly [P in S["phase"]]: PhaseHandler<I, Extract<S, { phase: P }>, S, R, H>; }; /** Runs in a fresh invocation after an abort mark and must commit a terminal outcome. */ abort(task: RunningTask<I, S, R>, runtime: TaskRuntime<I, S, R, H>, context: Context): Promise<void>; /** Convert a record stored by any older supported version; runs at reservation. */ migrate?( input: JsonValue, checkpoint: JsonValue, fromVersion: number, ): { input: I; checkpoint: S; }; readonly hooks?: H;};/** Typed executable task definition. */export interface Task<I, S extends { phase: string }, R, H extends object> { readonly definition: TaskDefinition<I, S, R, H>;}S["phase"]: thiếu một phase là lỗi biên dịch, và mỗi handler nhận task đã được thu hẹp về đúng nhánh checkpoint của nó.Hợp đồng của một phase handler được ghi ngay trên kiểu của nó: handler phải commit một checkpoint khác hoặc một kết quả qua runtime.commit(); trả về mà không tiến bộ bền thì task bị faulted (types.ts:222–230). Quy tắc này chặn một lỗi khó thấy: phase chạy xong mà quên commit; nếu không có nó, step sẽ thấy checkpoint cũ và gọi lại đúng phase đó, lặp vô hạn. abort() chạy trong một invocation mới sau khi có abort mark và cũng phải commit một kết quả. migrate() chạy lúc reservation để nâng bản ghi của version cũ.
/** Who owns a task: its conversation (a top-level task) or another task of the same conversation (a child task). */export type TaskOwnership = { readonly kind: "conversation" } | { readonly kind: "task"; readonly taskId: TaskId };/** How a waiting task treats the tasks it waits on (spec §5.5). */export type JoinPolicy = "failFast" | "allSettled";/** Creation options for a durable task. */export type TaskOptions = { /** Omitted: inherit the creator's carrier. "root": start at the adapter root without a stored carrier. */ readonly trace?: TelemetryCarrier | "root"; /** Immutable creation-time links, attached to the spans of this task's first invocation. */ readonly links?: readonly TelemetryCarrier[]; /** Required: a task always names its owner (spec §5.5). */ readonly ownership: TaskOwnership; /** * Default: the owner task's conversation, or the transaction's bound conversation; required for conversation-owned * tasks created by Session commits that are not bound to a conversation. */ readonly conversationId?: ConversationId; /** Conversation-owned tasks only: excluded from ordinary idle waits, conversation aborts, and cascades. */ readonly background?: boolean; /** * Its creator awaits it only in memory and does not resume after a restart, so it is pointless once the Harness that * created it is gone. When scheduling starts in a later Harness, the task, if still live, is abort-marked with reason * `restart`, and the mark cascades to its ordinary owned work, before any of it runs again. */ readonly abandonOnRestart?: boolean;};ownership là bắt buộc: không có task nào vô chủ. background chỉ dành cho task do conversation sở hữu; abandonOnRestart sẽ được giải thích ở mục restart.Ví dụ hoàn chỉnh nhỏ nhất trong repo là task đếm nhịp của test/examples/13-recovery.ts. Nó in "tick n", ngủ 50 ms, rồi sang nhịp sau, cho tới khi đếm đủ:
const Ticker = defineTask<{ to: number }, { phase: "tick"; n: number }, string>({ name: "example.ticker", version: 1, initial: () => ({ phase: "tick", n: 1 }), phases: { tick: async (task, runtime, taskContext) => { const n = task.state.checkpoint.n; // Save the intent before the effect. A memo keeps the first value // written under its name, so if the process dies after printing but // before the next checkpoint is saved, the rerun sees the memo and // does not print the same tick twice. if ((await runtime.memo(`printed-${n}`, taskContext)) === undefined) { await runtime.memo(`printed-${n}`, true, taskContext); console.log(`tick ${n}`); } reachedTick(n); // Save the outcome: the final result, or the next tick after a short // sleep. The sleep is stored with the checkpoint, so the task holds no // invocation meanwhile, and a Harness opened later continues it once // its time has come. if (n === task.input.to) { await runtime.commit( () => ({ status: "terminal", outcome: { status: "completed", result: `counted to ${n}` } }), taskContext, ); return; } await runtime.sleepUntil(runtime.now() + 50, { phase: "tick", n: n + 1 }, taskContext); }, }, abort: async (_task, runtime, taskContext) => { await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext); },});tick nhưng checkpoint mang số n; mỗi lần sleepUntil() là một checkpoint mới, nên "đã tới nhịp nào" là dữ liệu bền.Đọc ví dụ theo con mắt của một crash. Giả sử tiến trình chết ngay sau console.log("tick 2"), trước khi sleepUntil() commit. Checkpoint cuối đã lưu vẫn là { phase: "tick", n: 2 }, nên tiến trình sau chạy lại phase tick với n = 2. Nếu không có memo, "tick 2" sẽ in hai lần. Dòng 32 đọc memo printed-2: lần chạy đầu đã commit nó trước khi in, nên lần chạy lại thấy và bỏ qua. Ví dụ tự đóng Harness đúng ở thời điểm đó để mô phỏng crash (13-recovery.ts:60–62). Lưu ý giới hạn: memo đặt trước console.log nghĩa là nếu crash xảy ra giữa memo và lệnh in, "tick 2" sẽ không bao giờ được in. Memo chuyển "có thể in hai lần" thành "có thể không in lần nào"; chọn cái nào là việc của tác giả task.
TaskRuntime: những gì một phase được làm#
Mỗi phase handler nhận ba tham số: bản ghi đang chạy, một TaskRuntime, và một Chord Context (mang tín hiệu huỷ và span telemetry). Runtime là cánh cửa duy nhất của task ra thế giới bền.
export interface TaskRuntime<I, S, R, H extends object> extends DocumentObserver, DocumentReader { readonly taskId: TaskId<R>; readonly conversationId: ConversationId; /** * Aborted when the run is signalled by `abortTask()`, the Harness closes, or the invocation ends. Work still using it * after the invocation ended, such as a detached wait, is cancelled; it could not write anything anyway. */ readonly signal: AbortSignal; /** Registry snapshot of the current phase; refreshed at every phase boundary. */ readonly registry: RegistrySnapshot; /** The task's conversation's agent, resolved at most once per phase, at first use, and fixed for the phase. */ agent(context: Context): Promise<Agent>; /** `HarnessOptions.settings`, resolved at each access. */ readonly settings: Settings; /** `HarnessOptions.models`, resolved at each access. */ readonly models: Models; /** Calls `HarnessOptions.env` for the task's conversation; rejects with its error. */ env(context: Context): Promise<ExecutionEnv | undefined>; /** Handlers of this task's name from the extensions its conversation selects, in extension order. */ readonly hooks: HookRunner<H>; /** * Commit on the Session line after rereading the task. Rejects when the task is terminal, the invocation ended, the * Harness is closing, or, in a run invocation, the task carries an abort mark. A returned state replaces the task's * state in the same commit; returning nothing leaves it unchanged. `tx.createTask()` defaults to the task's * conversation. Inside the callback read only through `tx`: `snapshot()`, `documentState()`, `watchDoc()`, `getTask()`, and the other Session and runtime reads may queue on the line the callback holds, and then never resolve. */ commit( change: ( tx: Tx, current: RunningTask<I, S, R>, ) => NextTaskState<S, R> | undefined | Promise<NextTaskState<S, R> | undefined>, context: Context, ): Promise<void>; /** Read a durable memo of this task. */ memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>; /** Store `candidate` unless a memo already exists; return the durable winner. */ memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;agent() được phân giải nhiều nhất một lần mỗi phase và cố định trong phase đó; settings và models được đọc lại ở mỗi lần truy cập. Vì vậy đổi model giữa chừng chỉ có hiệu lực từ request sau: agent của phase prepare đã cố định, và generation còn ghim model vào checkpoint request (xem mục generation)./** Committed task record. */getTask<T>(id: TaskId<T>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, T> | undefined>;/** Resolve with the task's terminal receipt; rejects when the invocation ends. */waitForTask<T>(id: TaskId<T>, context: Context): Promise<SettledTask<T>>;/** * Abort a task this task owns, as `Harness.abortTask()` does, and resolve once it is terminal. Rejects for a task * another owns; resolves at once for a terminal one. */abortOwned(id: TaskId, context: Context): Promise<void>;/** * Live tasks this task owns directly, in ID order: also those an earlier invocation created, before a restart. Tasks * in conversations it owns are not included. Copies of the committed records. */ownedTasks(context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue>[]>;/** Outcomes of terminal tasks, in order; rejects when one is missing or not terminal. Used after a wait. */outcomes<T>(ids: readonly TaskId<T>[], context: Context): Promise<TaskOutcome<T>[]>;/** * Call `callback` once after about `delayMs` on the Harness's timer (`HarnessOptions.setTimer`), for pacing work * inside a phase, such as throttled progress commits. Not durable: the invocation's end disarms it, and after its end * nothing is armed. Returns a function that disarms it. Wait for time between phases with `sleepUntil()`. */setTimer(callback: () => void, delayMs: number): () => void;/** * Commit a sleep: the task holds no invocation until the Harness clock reaches `until`, then resumes at `checkpoint`. * Resolves once the sleep is stored, not when the time comes; the phase returns right after. Returning * `{ status: "sleeping", until, checkpoint }` from `commit()` is the same sleep, for one that must commit together * with other writes. */sleepUntil(until: number, checkpoint: S, context: Context): Promise<void>;waitForTask() chỉ là chờ trong bộ nhớ, kết thúc cùng invocation. Muốn chờ bền thì commit trạng thái waiting; muốn chờ thời gian bền thì sleepUntil().commit(): đọc lại, kiểm tra cổng, rồi thay trạng thái
Bên trong, runtime.commit() là một commit trên dòng commit (xem chương storage) đi qua hàm #gated() của scheduler:
#gated<T>( invocation: Invocation, change: (tx: Transaction, current: ErasedRunningTask) => T | Promise<T>, context: Context,): Promise<T> { if (invocation.ended) return Promise.reject(endedError(invocation)); return this.#session.commitWith( async (tx) => { if (invocation.ended) throw endedError(invocation); if (this.#closing) throw closedError(this.#session); const found = this.#live.get(invocation.taskId); if (found === undefined) throw new Error(`Task ${invocation.taskId} is terminal`); if (found.state.status !== "running") throw new Error(`Task ${invocation.taskId} is ${found.state.status}`); const current = found as ErasedRunningTask; if (invocation.mode === "run" && current.abortRequested) { throw new Error(`Task ${invocation.taskId} has a durable abort mark`); } return change(tx, current); }, context, { conversationId: invocation.conversationId, taskId: invocation.taskId }, );}tx.createTask() mặc định vào conversation của task, và mọi entry nối thêm được ghi byTaskId.Bốn điều kiện từ chối ở dòng 2335–2343 là hàng rào an toàn của cả hệ thống. Invocation đã kết thúc (ví dụ một promise bị bỏ rơi cố commit muộn) thì không được ghi. Harness đang đóng thì không được ghi. Task không còn running thì không được ghi. Và một invocation ở chế độ run không được commit khi bản ghi đã có abort mark: từ lúc mark được commit, chỉ handler abort() mới được quyết định. Khi callback trả về một trạng thái, #commitState() thay trạng thái trong cùng commit đó:
async #commitState( tx: Transaction, invocation: Invocation, current: ErasedRunningTask, next: NextTaskState<Checkpoint, JsonValue>,): Promise<void> { if (next.status === "waiting") await this.#validateWait(tx, invocation, current, next.on, next.policy); if (next.status === "sleeping") validateSleep(invocation, current.id, next.until); if (next.status === "terminal") { const overlay = overlayOf(tx); await this.#loadScopes(false); for (const record of overlay.tasks.values()) await this.#loadChain(parentOf(record), overlay); if (this.#hasOwnedLive(current.id, overlay)) { tx.setTask(withState(progressed(current), { status: "completing", outcome: next.outcome })); return; } } // A state the task committed that differs from its current one is progress: its restart count starts over. The // same checkpoint committed again, for example as intent before an effect, is not. const same = jsonEqual(current.state as unknown as JsonValue, next as unknown as JsonValue); tx.setTask(withState(same ? current : progressed(current), next));}restarts qua progressed().Vì checkpoint, entry, document và task con nằm trong cùng một commit, "đã gọi model và nhận được câu trả lời" với "đã nối câu trả lời vào transcript" không bao giờ lệch nhau: hoặc cả hai được lưu, hoặc không cái nào (spec.md:1929–1937).
memo(), sleepUntil(), setTimer()
memo: ((name: string, ...rest: readonly unknown[]) => { if (rest.length === 1) { return this.#read(invocation, async () => memoOf(this.#live.get(invocation.taskId), name)); } const candidate = rest[0] as JsonValue; return this.#gated( invocation, (tx, current) => { const winner = memoOf(current, name); if (winner !== undefined) return winner; tx.setTask({ ...current, memos: { ...current.memos, [name]: candidate } } as AnyTaskRecord); return candidate; }, rest[1] as Context, );}) as ErasedRuntime["memo"],sleepUntil: (until, checkpoint, context) => this.#gated( invocation, (tx, current) => this.#commitState(tx, invocation, current, { status: "sleeping", checkpoint, until }), context, ),setTimer() thì ngược lại, không bền chút nào: nó chỉ để nhịp công việc bên trong một phase, như việc commit phần trả lời đang stream mỗi 100 ms. Invocation kết thúc là mọi timer của nó bị gỡ, và gọi sau khi kết thúc thì không đặt gì (scheduler.ts:2290–2302).
Tạo task con, đọc kết quả, môi trường
TaskRuntime không có createTask(): task con được tạo bằng tx.createTask() bên trong runtime.commit(), cùng commit với checkpoint (tool thì có api.createTask(), xem mục tool). Transaction kiểm tra quan hệ sở hữu ngay khi tạo:
const ownership = options.ownership;let owner: AnyTaskRecord | undefined;if (ownership.kind === "task") { // Validated again against the owner's final candidate during assembly. owner = await this.#currentTask(ownership.taskId); this.#assertOpen(); if (owner === undefined) throw new Error(`Task owner ${ownership.taskId} does not exist`); if (options.background === true) throw new TypeError("A child task cannot be background"); if (options.conversationId !== undefined && options.conversationId !== owner.conversationId) { throw new Error(`A child task lives in its owner's conversation ${owner.conversationId}`); }}const conversationId = owner?.conversationId ?? options.conversationId ?? this.#scope.conversationId;const checkpoint = definition.initial(input);const id = await this.#host.storage.mintId<TaskId<R>>();this.#assertOpen();const record = copyJson( { id, conversationId, kind: definition.name, version: definition.version, input, trace: options.trace === "root" ? undefined : (options.trace ?? this.#context.telemetry.propagation?.inject(this.#context.telemetry)), links: options.links, ...(owner === undefined ? {} : { owner: owner.id }), background: options.background ?? false, abortRequested: false, ...(options.abandonOnRestart === true ? { abandonOnRestart: true } : {}), state: { status: "pending", checkpoint }, }, TABLE_JSON_COPY_OPTIONS,) as unknown as AnyTaskRecord;definition.initial(input) tính ngay khi tạo, nên bản ghi mới đã đủ để chạy mà không cần thêm thông tin nào.Sau một lần chờ, task đọc kết quả của các task nó chờ bằng runtime.outcomes(ids), hàm này từ chối nếu có task chưa terminal (scheduler.ts:2220–2231). runtime.env() gọi HarnessOptions.env cho conversation của task, với cwd hiện tại của agent (harness.ts:280–285). runtime.conversation(id) trả về một handle gắn với invocation: mọi thao tác của nó bị từ chối sau khi invocation kết thúc, nhưng submission đã nhận vẫn bền (harness.ts:498–533). runtime.context() đọc transcript và context của mô hình; scheduler giữ dải entry đã đọc của mỗi conversation trong bộ nhớ để lần đọc sau chỉ quét phần mới (scheduler.ts:2258–2285).
Cây sở hữu: task con, background, abort từ dưới lên#
Task và conversation cùng tạo thành một cây sở hữu duy nhất. Mỗi task khai báo owner khi được tạo: conversation của nó (task cấp cao nhất) hoặc một task khác (task con, luôn sống trong conversation của owner). Mỗi conversation hoặc vô chủ (ownerless) hoặc thuộc về một task, và đó là cách duy nhất cây vượt qua ranh giới conversation. Cạnh owner bất biến (spec.md:2308–2314). Đừng nhầm với quan hệ parent của fork: fork quyết định entry nào được thừa kế, còn owner quyết định abort và idle lan tới đâu (spec.md:247–250).
c1 (root, ownerless)
├── G pi.generation owner: conversation c1
│ ├── T1 pi.tool "subagent" owner: G
│ │ └── c2 (owner: T1) conversation của subagent
│ │ └── G2 pi.generation owner: conversation c2 → vẫn là việc sở hữu thường của T1
│ └── T2 pi.tool "bash" owner: G
└── B app.anchor background owner: conversation c1, ranh giới
└── c3 (owner: B)
└── G3 pi.generation không thuộc việc sở hữu thường của c1
Hai quy tắc suy ra từ cây (spec.md:2322–2354):
- Abort chảy xuốngÝ định huỷ của một owner còn sống (abort mark, hoặc kết quả đang giữ khác
completed) đánh dấu abort cho toàn bộ việc sở hữu thường của nó. Task background là ranh giới, trừ khi bị abort trực tiếp hoặc quaConversation.abort(context, { background: true }). - Việc con không sống lâu hơn lúc owner kết thúcMột task commit kết quả trong khi việc sở hữu thường còn sống thì được lưu thành
completing; scheduler chỉ ghiterminalkhi bên dưới không còn gì sống. Đây là dòng 1954–1956 của#commitState()ở trên.
Chờ: waiting với on và policy
Một phase có thể commit { status: "waiting", checkpoint, on, policy }. Task không giữ invocation nào trong lúc chờ và tiếp tục ở checkpoint khi mọi task trong on đã terminal. allSettled chờ tất cả; failFast thêm một việc: task đầu tiên trong on kết thúc (hoặc đang giữ) với kết quả khác completed làm mọi task còn sống khác trong on bị đánh dấu abort. Bản thân task đang chờ không bị đánh dấu; nó tỉnh dậy khi tất cả đã xong và tự đọc kết quả bằng runtime.outcomes() (spec.md:2364–2383). Scheduler kiểm tra lời chờ trước khi chấp nhận:
async #validateWait( tx: Transaction, invocation: Invocation, current: ErasedRunningTask, on: readonly TaskId[], policy: string,): Promise<void> { if (invocation.mode === "abort") throw new Error(`Abort handler of task ${current.id} cannot wait`); const overlay = overlayOf(tx); const targets: TaskId[] = []; for (const id of on) { const member = overlay.tasks.get(id) ?? this.#live.get(id) ?? (await this.#storage.task(id, this.#context)); if (member === undefined) throw new Error(`Task ${id} does not exist`); if (policy === "failFast" && member.owner !== current.id) { throw new Error(`Task ${current.id} can wait failFast only on tasks it owns; ${id} is not one`); } // A live child already holds the waiter (task-owned tasks are never background): waiting on it adds no edge. A // terminal task has no edges. if (member.state.status !== "terminal" && member.owner !== current.id) targets.push(id); } if (targets.length === 0) return; const cycle = await this.#waitCycle(current.id, targets, overlay); if (cycle !== undefined) { throw new Error( `Task ${current.id} cannot wait on ${cycle[0]}: ${[current.id, ...cycle].join(" -> ")} is a wait cycle`, ); }}completing), nên chỉ những task không thuộc quyền sở hữu mới đi qua phép tìm chu trình #waitCycle(). Handler abort() không được chờ.Kết quả bị giữ: completing
Sau mỗi commit có liên quan, một pass đối soát (reconcile; "pass" là một lần quét của scheduler, khác với lượt của mô hình) kiểm tra các task completing. Hàm #finalize() duyệt theo vòng: hoàn tất một task có thể giải phóng owner ngay phía trên nó, owner đó vào danh sách của vòng sau.
let round = [...this.#finalizeChecks];this.#finalizeChecks.clear();const done = new Set<TaskId>();const overlay = overlayOf(tx);while (round.length > 0) { const next = new Set<TaskId>(); for (const id of round) { const record = overlay.tasks.get(id) ?? this.#live.get(id); if (record?.state.status !== "completing" || done.has(id)) continue; if (this.#hasOwnedLive(id, overlay, done)) continue; const outcome = record.state.outcome; const terminal = withState(record, { status: "terminal", outcome }); tx.setTask(terminal); (overlay.tasks as Map<TaskId, AnyTaskRecord>).set(id, terminal); done.add(id); // REMINDER: only the scheduler writes `faulted` and `orphaned` (spec §5.4); their cleanup waits for this commit. if (outcome.status === "faulted" || outcome.status === "orphaned") { await this.#settleOutcome(tx, record, outcome); } const above = this.#nearestLiveAbove(record, overlay); if (above?.state.status === "completing") next.add(above.id); } round = [...next];}faulted, orphaned) chỉ được dọn dẹp phía Harness (kết thúc run, đánh dấu slot tool) trong commit cuối này, nên một generation bị fault vẫn giữ pi.live.run cho tới khi các tool của nó xong.Lan truyền abort và thứ tự từ dưới lên
abortTask() chỉ commit abort mark rồi chờ invocation đang chạy của task kết thúc (scheduler.ts:545–572). Việc đánh dấu cả cây con nằm ở một commit riêng của pass đối soát:
await this.#session.commitWith(async (tx) => { if (this.#closing) return; const queued = await this.#loadScopes(cascade); // A cascade from an abandoned owner passes its `restart` reason on; any other intent marks, or upgrades a // `restart` mark, as a request. Collected first, so a request wins over a `restart` mark of the same pass. const marks = new Map<TaskId, { readonly record: AnyTaskRecord; readonly reason: "restart" | undefined }>(); const mark = (record: AnyTaskRecord, reason: "restart" | undefined): void => { const staged = marks.get(record.id); if (staged !== undefined && staged.reason === undefined) return; if (record.abortRequested && (record.abortReason === undefined || reason !== undefined)) return; marks.set(record.id, { record, reason }); }; // Every live foreground task below a live owner with cancellation intent, which is its nearest one: a walk down // from each owner with intent does not enter another, whose own walk covers what is below it. Derived again on // every pass, as loading edges can reveal a cancelled owner. for (const id of this.#intent) { const owner = this.#live.get(id)!; // Only an owner abandoned after a restart, and nothing else, passes its reason on. const restart = owner.abortReason === "restart" && !failedOutcome(owner); for (const { node, live } of this.#walkDown(id, { enter: (below) => !this.#intent.has(below) })) { if (live !== undefined && !node.background) mark(live, restart ? "restart" : undefined); } } for (const id of checks) { const waiter = this.#live.get(id); if (waiter?.state.status !== "waiting" || !(await this.#anyFailed(waiter.state.on))) continue; // Every other live task: the failed one keeps its own outcome. for (const member of waiter.state.on) { const record = this.#live.get(member); if (record !== undefined && !failedOutcome(record)) mark(record, undefined); } } for (const { record, reason } of marks.values()) tx.setTask(withAbortMark(record, reason)); for (const id of queued) if (this.#belowCancelled({ conversation: id })) await this.#withdrawInputs(tx, id); await this.#finalize(tx);Thứ tự từ dưới lên đến từ một dòng điều kiện: task có abort mark coi việc sở hữu thường còn sống của nó là thứ đang chờ, nên invocation abort của nó chỉ được reserve khi bên dưới đã terminal. Nhờ vậy handler abort() luôn thấy kết quả cuối cùng của các task con, và mỗi task tự hoàn tác hiệu ứng của chính nó.
/** * Live tasks a task waits for before its next invocation: its live ordinary owned work when abort-marked, since * abort runs bottom-up, otherwise the live part of the `on` of a wait. */#waitingOn(record: AnyTaskRecord, first = false): readonly TaskId[] { if (record.abortRequested) return this.#ownedLive(record.id, { first }); if (record.state.status !== "waiting") return []; if (!first) return record.state.on.filter((id) => this.#live.has(id)); const live = record.state.on.find((id) => this.#live.has(id)); return live === undefined ? [] : [live];}Hệ quả cần nhớ: handler abort() không tạo được task con (task của nó đã mang abort mark, nên createTask với owner đó bị từ chối). Muốn bù trừ (compensate) ở mức cha thì làm ngay trong handler, hoặc tạo task background do conversation sở hữu và chờ bằng runtime.waitForTask() (spec.md:2385–2394).
Scheduler: open, reserve, run, step#
Mỗi Harness có một TaskScheduler, tạo trong constructor của HarnessImpl với các callback của Harness: phân giải agent, settings, môi trường, đồng hồ, onWake, timer, và settleOutcome để dọn dẹp phía Harness khi scheduler tự ghi kết quả (harness.ts:228–249). Scheduler không biết gì về run hay loại task; tri thức đó nằm trong callback (spec.md:2273). Bất biến trung tâm được ghi ngay ở đầu lớp:
/** * Durable task scheduler of one Harness. * * `#live` mirrors every committed non-terminal task record: pending, running, waiting, sleeping, and completing. The * synchronous commit listener updates it on the Session line, so code running on the line reads exactly the committed * state from it. * * Tasks and conversations form one ownership tree (spec §5.5): a task's parent is its owner task, or its conversation; * a conversation's parent is its owner task, if any. Walks up that tree decide what reaches one task: a cancelling * owner, a scope. `#below` lists the tree downward around live work, so what lies below one node is a walk down from * it: a task's live ordinary owned work, which holds its outcome as `completing` and delays its abort handler, a * scope's tasks, a cascade's reach. No pass visits every live task; the sets below hold each pass's candidates. * * The ownership indexes are bounded by live work: an owner edge and an ended task's ownership fields are kept only while * something is listed below them in `#below`, so only the chains above live tasks are known, whole. Work that appears * later below a dropped node finds it unknown and loads its chain from storage again. * * Invariant: every task transition is decided and written by one callback serialized on the Session line. That covers * reservation, marks, runtime commits, finalization, and the synchronous step before each phase, which applies the * precedence rules and writes a fault or handover. Handlers and joins run off the line. An invocation ends inside the * step that decides its end, so a runtime commit it queued either lands before that decision or is rejected. */#live là bản sao trong bộ nhớ của mọi bản ghi chưa terminal, cập nhật đồng bộ bởi listener commit #observe() (scheduler.ts:624), nên code chạy trên dòng commit đọc đúng trạng thái đã commit.open(): đối soát sau crash
async open(context: Context): Promise<void> { this.#session.observeCommits((publication) => this.#observe(publication)); this.#session.subscribeClose(() => this.#seal()); this.#unsubscribeRegistry = this.#registry.subscribe(() => { this.#notifyBlocked(); this.#kick(); }); const limit = this.#restartLimit(); const parked: AnyTaskRecord[] = []; await this.#session.commitWith(async (tx) => { // Every table read before the first write. const scans = []; for (const status of LIVE_STATUSES) { scans.push(await scanAll((cursor) => tx.scanTasks({ status }, SCAN_PAGE_SIZE, cursor))); } for (const records of scans) { for (const record of records) { this.#track(undefined, record); this.#resumed.add(record.id); if (record.abandonOnRestart === true) this.#abandoned.add(record.id); if (record.state.status === "running") { const restarts = (record.restarts ?? 0) + 1; const pending = withState(record, { status: "pending", checkpoint: record.state.checkpoint }); tx.setTask({ ...pending, restarts } as AnyTaskRecord); if (restarts >= limit && !record.abortRequested) parked.push(record); } if (record.state.status === "waiting" && record.state.policy === "failFast") { this.#failFastChecks.add(record.id); } } } }, context); // Derive abort marks a crash left unapplied below cancelled owners, and finalize held outcomes. this.#cascadePending = true; this.#scheduleReconcile(); this.#scheduleWake();open().Đọc kỹ dòng 394–398: một bản ghi còn running khi mở nghĩa là tiến trình trước chết trong lúc chạy nó, vì close() luôn ghi các task nó dừng về pending (xem join() bên dưới). waiting và sleeping giữ nguyên; chúng không có invocation nên không có gì bị cắt ngang. Mở mà không gọi resume() thì không chạy gì cả, điều một công cụ chỉ đọc (inspector) cần (spec.md:744–755).
resume(), kick và drain
resume() bật lập lịch, đánh thức các sleep đã tới hạn rồi gọi #kick() (scheduler.ts:423–427). submit() và Submission.wait() (submissions.ts:75–77, :115–116), waitForTask(), waitForIdle() (harness.ts:344–352), compact() và abort() của conversation cũng gọi nó, nên host chờ một submission sau khi mở lại thì công việc cũ cũng chạy tiếp.
#kick(): void { this.#scheduleWake(); this.#dirty = true; if (this.#draining || !this.#enabled || this.#closing) return; this.#draining = true; // Never commit synchronously from a commit or registry listener. queueMicrotask(() => void this.#drain());}async #drain(): Promise<void> { try { while (this.#dirty && this.#enabled && !this.#closing) { this.#dirty = false; for (const reservation of await this.#reserve()) this.#start(reservation); } } catch (error) { // As in `#reconcile()`: the reservation commit runs no extension code. this.#failSession(error); } finally { this.#draining = false; // A wakeup that arrived after the loop's last check still needs its pass. if (this.#dirty) this.#kick(); }}#dirty; một microtask chạy #drain() cho tới khi không còn gì đổi.#reserve(): chọn task chạy được trong một commit
await this.#session.commitWith(async (tx) => { if (!this.#enabled || this.#closing) return; for (const id of this.#abandoned) { const record = this.#live.get(id); if (record !== undefined && !record.abortRequested) tx.setTask(withAbortMark(record, "restart")); } if (this.#abandoned.size > 0) { abandoned = [...this.#abandoned]; return; } await this.#loadScopes(false); // Every pass picks up sleeps whose time has come: a pass started by any change may be the first to run after // a deadline, and the wake it schedules no longer keeps a timer for a due sleep. this.#wakeDue(); this.#unparkRaised(); // Taken once per pass, and only when some task is a candidate. let snapshot: RegistrySnapshot | undefined; for (const id of [...this.#runnable]) { const record = this.#live.get(id); if (record === undefined || record.state.status === "completing" || this.#invocations.has(id)) { this.#runnable.delete(id); continue; } // It returns once what it waits for ends, its record changes, or the work below it ends. if (this.#waitingOn(record, true).length > 0) { this.#runnable.delete(id); continue; } // It returns at the first pass after its time comes, which the timer, `resume()`, or any change starts. if (this.#notDue(record)) { this.#runnable.delete(id); continue; } // It returns when `retry()` clears its restart count, or at a pass after `settings.restartLimit` rose. if (this.#parked(record)) { this.#runnable.delete(id); // Newly parked without a commit, as after a lowered limit: the graph must hear it. if (!this.#parkedTasks.has(id)) queueMicrotask(() => this.#notifyBlocked()); this.#parkedTasks.add(id); continue; }abandonOnRestart còn sống, pass đầu chỉ đánh dấu chúng và không reserve gì (dòng 1477–1480), để pass sau nhìn thấy mark đó. const runnable = record as RunnableTaskRecord; const mode = record.abortRequested ? "abort" : "run"; // Work below an owner with cancellation intent waits for its cascade mark instead of running a phase; schedule // the cascade, which may not have run yet. if (mode === "run" && !record.background && this.#belowCancelled(parentOf(record))) { this.#cascadePending = true; this.#scheduleReconcile(); continue; } // An abort handler cut off `restartLimit` times in a row would run forever; nothing is left to park it for. if (mode === "abort" && (record.restarts ?? 0) >= this.#restartLimit()) { const message = `Abort handler of task ${id} (${record.kind}) was cut off ${record.restarts} times`; await this.#terminate(tx, record, { status: "faulted", error: { message, detail: { reason: "restart_limit" } }, }); faulted.push({ record, message }); continue; } snapshot ??= this.#registry.snapshot(); const resolution = this.#resolve(runnable, snapshot); if (resolution.kind === "blocked") { // An abandoned task waits for its definition, so its abort handler can clean up. if (mode === "abort" && record.abortReason === undefined) { await this.#terminate(tx, record, { status: "orphaned", reason: resolution.reason }); } continue; } if (resolution.record !== runnable || record.state.status !== "running") { tx.setTask( withState(resolution.record, { status: "running", checkpoint: resolution.record.state.checkpoint, }), ); } // Registered on the line, so marks and later reservations see it and close joins it; `#end` makes it a // candidate again. this.#runnable.delete(id); const invocation = this.#createInvocation(runnable, mode, installerOf(snapshot, resolution.task)); reservations.push({ invocation, task: resolution.task, snapshot });}running được ghi cùng nhau. Invocation được đăng ký ngay trên dòng commit (dòng 1551), nên mark và pass reservation sau thấy nó, và close() join được nó.#run() và #step(): một step trước mỗi phase
async #run(reservation: Reservation): Promise<void> { const invocation = reservation.invocation; const state = { task: reservation.task, snapshot: reservation.snapshot, reported: undefined as ReportedTask }; const phase: Phase = { snapshot: () => state.snapshot, task: () => state.task, agent: undefined }; const runtime = this.#runtime(invocation, phase); let current = await this.#step(invocation, (tx, next) => this.#decide(tx, next, undefined, state, invocation.extension, invocation.context), ); while (current !== undefined && !this.#closing) { const task = current; current = await this.#phase(invocation, task, async () => { const checkpoint = task.state.checkpoint; // Each phase handler resolves its agent afresh, at first use. phase.agent = undefined; let previous: PhaseResult; try { await erased(state.task).phases[checkpoint.phase]!(task, runtime, invocation.context); previous = { checkpoint }; } catch (error) { previous = { checkpoint, failure: { error } }; } return this.#step(invocation, (tx, next) => this.#decide(tx, next, previous, state, invocation.extension, invocation.context), ); }); }}Step là một commit trên dòng commit; nó đọc bản ghi đã commit và áp quy tắc đầu tiên khớp. Spec liệt kê sáu quy tắc (spec.md:1988–2012): (1) task đã terminal, completing, waiting hay sleeping thì dừng; (2) Harness đang đóng thì dừng, giữ checkpoint; (3) chế độ run mà có abort mark thì dừng, invocation abort sẽ chạy sau; (4) phase ném lỗi thì ghi faulted; (5) checkpoint đã đổi thì làm mới registry snapshot rồi chạy tiếp hoặc handover; (6) checkpoint không đổi thì ghi faulted. Quy tắc 1 và 2 nằm trong #step(), phần còn lại trong #decide():
): Decision { // 3. abort mark: end; a fresh abort invocation starts once the task's ordinary owned work is gone. if (current.abortRequested) return false; // An owner's cancellation intent ends it too, before its cascade marks it; reservation then holds it back. if (!current.background && this.#belowCancelled(parentOf(current))) { this.#cascadePending = true; this.#scheduleReconcile(); return false; } if (previous === undefined) return true; // 4. uncaught error. if (previous.failure !== undefined) return { fault: previous.failure.error }; // 6. no durable progress. if (jsonEqual(current.state.checkpoint, previous.checkpoint)) { const message = `Task ${current.kind} phase ${previous.checkpoint.phase} returned without durable progress`; return { fault: new Error(message) }; } // 5. progress: refresh the snapshot; hand over to a replacement definition that can take the task. state.snapshot = this.#registry.snapshot(); const next = state.snapshot.task(current.kind); if (next !== state.task) { if (next !== undefined && canReserve(next, current)) { tx.setTask(withState(current, { status: "pending", checkpoint: current.state.checkpoint })); return false; } if (state.reported === undefined || state.reported.task !== next) { state.reported = { task: next }; this.#report( new Error(`Task ${current.id} keeps running under its old ${current.kind} definition`), { type: "task_outdated", ...this.#aboutTask({ ...recordTask(current), extension }), reason: next === undefined ? "missing_task" : "incompatible_task", }, context, ); } } return true;}async #step( invocation: Invocation, decide: (tx: Transaction, current: ErasedRunningTask) => Decision,): Promise<ErasedRunningTask | undefined> { let fault: { readonly error: unknown } | undefined; try { const continued = await this.#session.commitWith(async (tx) => { const found = this.#live.get(invocation.taskId); const current = found?.state.status === "running" ? (found as ErasedRunningTask) : undefined; const decision = current !== undefined && !this.#closing ? decide(tx, current) : false; if (decision === true) return current; this.#end(invocation); if (decision !== false) { fault = { error: decision.fault }; const message = errorMessage(decision.fault); invocation.fault = { status: "faulted", error: { message } }; await this.#terminate(tx, current!, invocation.fault); } return undefined; }, withoutAbortSignal(invocation.context));#end() chạy bên trong commit, một runtime.commit() mà invocation xếp hàng trước đó hoặc đã vào storage trước step (và được tính), hoặc tới sau và bị từ chối bởi #gated().close(): không phải crash
Khi Harness đóng, scheduler báo hiệu mọi invocation, chờ chúng trả về, rồi ghi các task bị dừng về pending thẳng vào storage. Vì thế lần mở sau không đếm restart cho chúng; chỉ một tiến trình chết thật mới để lại bản ghi running.
async join(): Promise<void> { const stopped = [...this.#invocations.values()]; for (const invocation of stopped) invocation.controller.abort(); await Promise.allSettled(stopped.map((invocation) => invocation.done)); if (stopped.length === 0) return; // A close is not a crash: a task it stopped is `pending` again, so the next open counts no restart for it. Read // on the line after commits admitted before the seal, such as an abort mark, so the write keeps them. A failure // leaves them `running`, counted as if cut off; the Storage guard fails the Session and reports it. await this.#writeAtClose(() => stopped.flatMap((invocation) => { const record = this.#live.get(invocation.taskId); if (record?.state.status !== "running") return []; const value = withState(record, { status: "pending", checkpoint: record.state.checkpoint }); return [{ type: "task", value } as const]; }), ).catch(() => {});}AbortSignal làm close() treo (spec.md:5591–5593).Sleep, timer và wake#
Trước 1.4.0, task có runtime.sleep(): chờ ngay trong phase. CHANGELOG giải thích vì sao nó bị xoá: chờ trong phase thì giữ một invocation, và sau restart phase chạy lại từ đầu nên lần chờ cũng bắt đầu lại (CHANGELOG.md:7). Một backoff 10 phút, mỗi lần tiến trình khởi động lại, lại đếm 10 phút từ đầu; và một Durable Object đang chờ không thể ngủ đông (hibernate). Bây giờ sleep là một trạng thái:
runtime.sleepUntil(until, checkpoint)commit{ status: "sleeping", until, checkpoint }và resolve khi đã lưu, không phải khi tới giờ; phase trả về ngay sau đó. Muốn commit sleep cùng các thay đổi khác thì trả trạng thái đó từruntime.commit()(README.md:162).- Việc sau lúc chờ nằm ở phase kế, và những gì nó cần nằm trong checkpoint. Deadline được tính một lần rồi lưu, nên không bị đẩy lùi mỗi lần chạy lại (spec.md:2417–2431).
untilphải là số hữu hạn không âm, và handlerabort()không được ngủ (scheduler.ts:2489–2494). Abort mark kết thúc giấc ngủ ngay:#notDue()coi task có mark là đã tới hạn (scheduler.ts:1430–1435).
Phần còn lại là ai đánh thức. Scheduler không biết tiến trình có còn sống tới lúc đó không, nên nó trả lời câu hỏi "khi nào Session cần một tiến trình chạy" bằng wake():
wake(): Wake { const now = this.#now(); // A reconcile pass with work to do is work too: it finalizes held outcomes and marks cascades, at open as // recovery. A process that dies before it lands must be woken again. A pass with nothing to do is not. let active = this.#invocations.size > 0 || this.#reconcilingWork > 0 || this.#reconcileWork(); let at: number | undefined; let snapshot: RegistrySnapshot | undefined; let snapshotFailed = false; for (const record of this.#live.values()) { const state = record.state; if (state.status === "completing") continue; if (state.status === "waiting" && !record.abortRequested && state.on.some((id) => this.#live.has(id))) continue; if (state.status === "sleeping" && !record.abortRequested && state.until > now) { at = at === undefined ? state.until : Math.min(at, state.until); continue; } if (active) continue; if (this.#parked(record)) continue; // An abort invocation waits for the live work below it, which counts itself. if (record.abortRequested && this.#waitingOn(record, true).length > 0) continue; // A task no definition can take waits for a registry change, except an abort-marked one, which is orphaned. if (!record.abortRequested || record.abortReason !== undefined) { if (snapshot === undefined && !snapshotFailed) { try { snapshot = this.#registry.snapshot(); } catch (error) { snapshotFailed = true; this.#report(error, REGISTRY); } } if (snapshot !== undefined && "reason" in this.#fit(record, snapshot.task(record.kind))) continue; } active = true; } return at === undefined ? { active } : { active, at };}completing và lời chờ trên task còn sống không tính là active; việc chúng chờ mới tính. Một pass đối soát đang chờ chạy cũng là active (dòng 481), vì nó có thể hoàn tất kết quả bị giữ sau crash.Có hai cách dùng giá trị này. Mặc định, khi lập lịch đang bật, scheduler đặt một timer "foreground" cho at; timer này giữ tiến trình Node sống, vì một task đang ngủ là việc còn dang dở. Với HarnessOptions.onWake, scheduler không đặt timer nào mà báo cho host mỗi khi giá trị đổi; host tự đặt báo thức (alarm) và gọi resume() khi nó reo:
#scheduleWake(): void { if (this.#wakeScheduled || this.#closing) return; this.#wakeScheduled = true; queueMicrotask(() => { this.#wakeScheduled = false; if (this.#closing) return; const wake = this.wake(); if (this.#onWake === undefined) { this.#armTimer(this.#enabled ? wake.at : undefined); return; } const last = this.#lastWake; if (last !== undefined && last.active === wake.active && last.at === wake.at) return; try { this.#onWake(wake); // Only a delivered wake counts: after a throw, the next change or kick offers it again. this.#lastWake = wake; } catch (error) { this.#report(error, ON_WAKE); } });}#armTimer(at: number | undefined): void { if (this.#timer !== undefined && this.#timer.at === at) return; this.#timer?.disarm(); this.#timer = undefined; if (at === undefined || this.#closing) return; const disarm = this.#timers.foreground(() => { this.#timer = undefined; this.#wakeDue(); this.#kick(); }, at - this.#now()); this.#timer = { at, disarm };}/** Make every sleeping task whose time has come a reservation candidate. */#wakeDue(): void { for (const id of this.#sleeping) { const record = this.#live.get(id); if (record !== undefined && !this.#notDue(record)) this.#runnable.add(id); }}#wakeDue() lọc qua #notDue()), nên reo sớm chỉ đặt lại timer. Mọi pass reservation cũng gọi #wakeDue(), nên một sleep đã tới hạn chạy ở pass đầu tiên sau deadline, dù pass đó do một thay đổi khác kích hoạt.Mọi timer của Session và Harness đi qua HarnessOptions.setTimer, chia hai loại. Timer "background" chỉ giải phóng bộ nhớ (đuổi document, context không dùng); host có thể từ chối bằng cách trả undefined, và bản mặc định từ chối chúng trong Cloudflare Workers vì một timer đang chờ giữ Durable Object không cho ngủ đông. Timer "foreground" nhịp hoặc tiếp tục công việc; host ném lỗi hay từ chối thì Harness tự đặt bằng setTimeout và báo lỗi một lần (timers.ts:12–36, timers.ts:46–79). Truyền cả setTimer và now thì Harness chạy trên đồng hồ giả; mọi tiến trình mở cùng một storage phải dùng cùng một đồng hồ, vì deadline được lưu theo now (README.md:164).
Mẫu dùng onWake cho một Durable Object của Cloudflare (đặt báo thức dự phòng trước Harness.open, báo thức nhịp tim khi active, gọi resume() trong alarm()) nằm ở README.md:166–210; phần lưu trữ của nó thuộc chương storage. Test harness-generation-recovery.test.ts:165–210 cho thấy cả chu trình: một backoff retry được lưu thành sleeping tới 61 000, sau khi mở lại wake() trả { active: false, at: 61_000 }, gọi resume() sớm không gửi request nào, và khi đồng hồ tới 61 000 thì request được gửi lại.
Restart, parking, task bị chặn và migration#
Vòng lặp crash và việc đỗ task
Có một mặt trái của "mở lại là chạy tiếp": công việc làm tiến trình chết một cách tất định, ví dụ cạn bộ nhớ trước checkpoint kế, sẽ được chạy tiếp và lại giết tiến trình, mãi mãi (README.md:216). Phòng tuyến là trường restarts. open() cộng một cho mỗi bản ghi còn running (đã thấy ở dòng 394–398); trạng thái khác mà task tự commit xoá bộ đếm (progressed()). Khi restarts chạm settings.restartLimit (mặc định 5, agent.ts:56–62), task bị đỗ: không được reserve, hiện trong inspect() và task graph với lý do restart_limit, không tính là active trong wake(), và được báo một lần qua onReport với kiểu task_parked.
async retry(id: TaskId, context: Context): Promise<void> { await this.#session.commitWith(async (tx) => { const current = await tx.task(id); if (current === undefined) throw new Error(`Task ${id} does not exist`); const status = current.state.status; if (status === "terminal" || status === "completing" || !this.#parked(current)) return; tx.setTask(progressed(current)); }, context); this.resume();}Hai chi tiết cho thấy cơ chế được nghĩ kỹ. Thứ nhất, mọi task đang chạy khi tiến trình chết đều bị đếm, không chỉ thủ phạm, nên một stream dài chạy cạnh thủ phạm cũng có thể bị đỗ; vì vậy host được khuyên hiển thị task bị đỗ và cho người dùng nút retryTask() (spec.md:2201–2206). Thứ hai, abort mark đặt lại bộ đếm (withAbortMark() xoá restarts, scheduler.ts:2389–2396), nên handler abort() của một task bị đỗ vẫn chạy; nhưng nếu chính handler đó bị cắt ngang đủ số lần, task kết thúc faulted với lý do restart_limit (dòng 1522–1529 của #reserve()). Test mô phỏng crash bằng một storage "chết" (mọi lời gọi treo, nên ngay cả bản ghi pending lúc đóng cũng không được ghi), mở lại ba lần và kiểm tra task bị đỗ với restarts: 3, rồi retryTask() chạy nó tới xong (harness-blocked.test.ts:135–177).
Definition thiếu, cũ hơn, hay cần migration
Bản ghi lưu kind và version; code nằm trong registry của tiến trình. Hai thứ có thể lệch nhau: extension chưa được cài lại sau restart, hoặc đã được nâng version. Mỗi lần reserve, scheduler tra definition theo tên trong snapshot registry hiện tại:
/** Resolve the record's definition by kind, migrating an older stored version. */#resolve(record: RunnableTaskRecord, snapshot: RegistrySnapshot): Resolution { const fit = this.#fit(record, snapshot.task(record.kind)); if ("reason" in fit) return { kind: "blocked", reason: fit.reason }; if (!fit.migrates) return { kind: "ready", task: fit.task, record }; const definition = erased(fit.task); try { if (definition.migrate === undefined) throw missingMigration(record, definition); const migrated = definition.migrate(record.input, record.state.checkpoint, record.version); const state = { ...record.state, checkpoint: copyJson(migrated.checkpoint) }; const migratedRecord = { ...record, version: definition.version, input: copyJson(migrated.input), state }; return { kind: "ready", task: fit.task, record: migratedRecord as RunnableTaskRecord }; } catch (error) { this.#failedMigrations.set(record.id, { task: fit.task, error }); // Blocked now without a commit: the graph and the host's wake must hear it. queueMicrotask(() => this.#notifyBlocked()); this.#scheduleWake(); const extension = installerOf(snapshot, fit.task); this.#report(error, { type: "migration_failed", ...this.#aboutTask({ ...recordTask(record), extension }) }); return { kind: "blocked", reason: "migration_failed" }; }}#fit(record: Pick<AnyTaskRecord, "id" | "version">, task: AnyTask | undefined): Fit { if (task === undefined) return { reason: "missing_task" }; const version = task.definition.version; if (version === record.version) return { task, migrates: false }; if (version < record.version) return { reason: "task_too_old" }; const failed = this.#failedMigrations.get(record.id); if (failed?.task === task) return { reason: "migration_failed", error: failed.error }; return { task, migrates: true };}running. Migration hỏng được nhớ theo đối tượng definition, nên chỉ thử lại khi registry trả về một definition khác (dòng 1617).Nguyên tắc quan trọng: Harness không bao giờ kết thúc một task chỉ vì thiếu code, cả lúc mở lẫn về sau. Task bị chặn giữ nguyên bản ghi, vẫn sống, vẫn làm idle wait phải chờ, và được xét lại mỗi khi registry đổi (spec.md:2218–2227). Chỉ khi task bị chặn bị huỷ thì nó mới kết thúc orphaned, vì không còn code nào để chạy handler abort() (abort() ở scheduler.ts:553–561, và dòng 1533–1537 của #reserve()). Ví dụ migration thật duy nhất trong các task dựng sẵn là pi.tool version 2, thêm trường phân biệt kind cho input:
export const ToolTask = defineTask<ToolTaskInput, ToolTaskCheckpoint, ToolTaskResult, ToolHooks>({ name: "pi.tool", version: 2, initial: () => ({ phase: "call" }), // Version 1 had only model-issued calls. migrate: (input, checkpoint) => ({ input: { kind: "model", ...(input as { assistant: EntryId; callId: string }) }, checkpoint: checkpoint as ToolTaskCheckpoint, }), phases: {Khi definition được thay ngay trong lúc chạy (extension được cài lại cùng tên), task đang chạy không bị ngắt: ở ranh giới phase kế, #decide() thấy đối tượng definition đã khác; nếu definition mới nhận được bản ghi thì step ghi task về pending (dòng 1837–1839) và pass reservation sau chạy nó bằng code mới. Nếu không nhận được, task chạy tiếp bằng code cũ và báo task_outdated (spec.md:2294–2304).
abandonOnRestart: task mà người tạo chỉ chờ trong bộ nhớ
Một số task chỉ có ý nghĩa với người tạo ra chúng trong cùng tiến trình. Ví dụ: một tool không replay-safe gọi một tool lồng nhau rồi await kết quả. Sau restart, tool cha không chạy lại (nó sẽ nhận kết quả interrupted), nên chẳng ai cần kết quả của lời gọi con nữa; để nó chạy tiếp chỉ tốn tiền và có thể gây hiệu ứng thừa. TaskOptions.abandonOnRestart đánh dấu những task như vậy (types.ts:385–390). Khi một Harness sau bắt đầu lập lịch, pass reservation đầu tiên đánh dấu abort chúng với abortReason: "restart" (dòng 1473–1480 ở trên), và lý do này lan xuống việc sở hữu thường. Mở chỉ để đọc thì không đánh dấu gì (spec.md:2244–2256). Handler abort() của pi.tool đọc lý do đó để nói đúng điều đã xảy ra:
abort: async (task, runtime, context) => { const call = await readCall(runtime, task.input, context); // Abandoned after a restart (`TaskOptions.abandonOnRestart`): say whether the call may have run. const [code, message] = task.abortReason !== "restart" ? ["aborted", `Tool ${call.name} was aborted`] : task.state.checkpoint.phase === "execute" ? ["interrupted", `Tool ${call.name} was interrupted by a restart and may have partially run`] : ["abandoned", `Tool ${call.name} was not started: its caller ended with a restart`]; const ending = { status: "aborted" } as const; await settle(runtime, task.input, call, ending, (slot) => fromSlot(slot, code, message), context);},execute) thì tool có thể đã chạy một phần: interrupted. Chưa ghi (call) thì chắc chắn chưa chạy: abandoned.Hai điểm khác biệt với abort thường. Task có mark restart mà thiếu definition thì chờ definition thay vì bị orphaned, để handler abort() của nó vẫn chạy và dọn dẹp được khi extension được cài lại; một yêu cầu abort thật (abortTask(), conversation abort) thay mark restart và khi đó task mới bị orphaned (scheduler.ts:448–453). Người dùng hiếm khi phải tự đặt cờ này: lời gọi lồng nhau và task con mà một tool không replay-safe tạo qua api.createTask() tự nhận nó (tool.ts:376).
pi.generation: từ transcript tới câu trả lời#
Một task pi.generation ứng với một câu trả lời của mô hình, kể cả các lần retry và poll của câu trả lời đó. Một lần chạy (run) gồm nhiều lượt (turn) nên gồm nhiều generation nối tiếp: generation trả lời bằng lời gọi tool sở hữu các task tool, chờ chúng, rồi trao run cho một generation mới. Input của task rỗng; các input của run nằm trong document pi.live, không nằm trong task (spec.md:4181–4182). Đây là document mà mọi task dựng sẵn cùng ghi:
/** Built-in live conversation state: run control and presentation of the current generation and tool round. */export type LiveState = { /** Run control: the task that settles the run's inputs, and those inputs; present exactly while busy. */ run?: { taskId: TaskId; inputs: SubmissionId[] }; /** Presentation of the current generation attempt. */ generation?: { attempt: number; /** Committed throttled partial of the in-flight response. */ message?: JsonRepresentation<AssistantMessage>; /** Durable backoff before the next attempt. */ retry?: { at: number; error: string }; /** Provider-side deferred response being polled. */ deferred?: { pollAt: number }; }; /** The current tool round in call order, from the tool-calling answer until the generation's `tools` phase ends it. */ tools?: ToolSlot[]; /** * Nested calls of running tool calls, in creation order, so a parent precedes its children and task IDs ascend. A * call's nested slots are removed when the call settles. */ nestedTools?: NestedToolSlot[]; /** Live compaction tasks in task ID order; absent when none. */ compactions?: CompactionStatus[]; /** * The newest compaction failure, so a client can show it after the task ended. A compaction that produces a summary * removes it; one that is aborted or finds nothing to compact leaves it. */ compactionFailure?: CompactionFailure;};run có mặt đúng khi conversation bận: taskId là task đang chịu trách nhiệm settle các input. Mọi thứ khác là trình bày cho UI, và cũng là nơi lưu phần đầu ra dở dang để phục hồi.Checkpoint của generation là một kiểu hợp năm phase:
export type GenerationCheckpoint = | { phase: "prepare"; attempt: number; /** The blocking compaction this generation waited for; it starts no other compaction (spec §8.3). */ compacted?: TaskId<CompactionResult>; /** Error text of the overflow that started `compacted`; checked once when `prepare` resumes. */ overflow?: string; /** Set when the blocking threshold started `compacted`; its outcome is checked once when `prepare` resumes. */ threshold?: true; } | { phase: "request"; attempt: number; compacted?: TaskId<CompactionResult>; model: ModelRef; thinkingLevel: ModelThinkingLevel; /** The settings' request options when preparation committed; a resend after recovery uses them unchanged. */ streamOptions: ConversationStreamOptions; /** Newest entry included in the request. */ cutoff: EntryId; } | { phase: "retry"; attempt: number; compacted?: TaskId<CompactionResult>; until: number } | { phase: "poll"; attempt: number; compacted?: TaskId<CompactionResult>; model: ModelRef; cutoff: EntryId; handle: DeferredHandle; pollAt: number; } | { /** Waiting on the round's tool tasks, which the generation owns (spec §8.5). */ phase: "tools"; /** The tool-calling answer. */ assistant: EntryId; /** Tool tasks created so far, in call order; grows by one per started call of a sequential round. */ tools: TaskId<ToolTaskResult>[]; /** Calls of a sequential round not started yet, in call order. */ pending: string[]; };request ghim (pin) model, mức suy nghĩ, tuỳ chọn stream và cutoff (entry mới nhất được gửi). Gửi lại sau phục hồi dùng đúng các giá trị này, không đọc lại settings.
prepare ──commit pi.system, ghim cutoff──► request ──stream, partial ≤ 1 lần/100 ms──► classify
│
└─ vượt ngưỡng chặn ──► waiting trên compaction chặn ──► prepare
classify chọn một nhánh, mỗi nhánh là một commit:
stop / length .............. answer(): final boundary, settle input, có thể mở run mới
toolUse + lời gọi .......... tools: waiting allSettled trên pi.tool × n
└─► finishToolRound() ──► generation mới, cùng run
error, tràn context ........ waiting trên compaction chặn (chỉ một lần) ──► prepare
error, thử lại được ........ retry: sleeping tới until ──► prepare, attempt + 1
deferred ................... poll: sleeping tới pollAt ──► fetchDeferred() ──► classify
lỗi khác ................... kết thúc failed; input unanswered với model_error
pi.generation. Mọi lần chờ (compaction, tool, backoff, poll) là trạng thái waiting hoặc sleeping đã commit, không có lần chờ nào trong bộ nhớ.prepare: dựng system prompt và chốt request
prepare phân giải agent của conversation (một lần cho phase), kiểm tra model có tồn tại không (không thì kết thúc failed với lý do no_model), render các section của system prompt và tính các entry pi.system cần thêm. Cơ chế section và entry hệ thống theo vị trí thuộc chương mô hình khái niệm; ở đây chỉ cần biết planSystemEntries() trả về những entry mô tả phần prompt và tool đã đổi (generation.ts:155–176). Phần cuối của phase là nơi các quyết định được ghi:
const entries = planSystemEntries(view, desired, agent.tools, runtime.now());const threshold = compacted === undefined ? thresholdCompaction(view, entries, resolved.contextWindow, compactionPolicy(settings, model)) : undefined;if (threshold === "blocking") { // Compact first and prepare again; the transcript is unchanged until the compaction appends. await runtime.commit(async (tx): Promise<Next> => { const child = await createCompaction(tx, conversationId, { reason: "threshold" }, runtime.taskId); const checkpoint = { phase: "prepare", attempt, compacted: child, threshold: true } as const; return { status: "waiting", checkpoint, on: [child], policy: "allSettled" }; }, context); return;}await runtime.commit(async (tx) => { let cutoff = (await tx.scanEntries({ conversationId }, 1)).items[0]?.id; for (const entry of entries) cutoff = (await tx.appendEntry(SystemEntry, conversationId, entry)).id; if (cutoff === undefined) throw new Error(`Conversation ${conversationId} has no entries to send`); // Checked in this commit, so a compaction admitted during preparation counts. if (threshold === "background" && (await tx.doc(LiveDoc, conversationId)).compactions === undefined) { await createCompaction(tx, conversationId, { reason: "threshold" }); } const request = { attempt, ...(compacted === undefined ? {} : { compacted }), model, thinkingLevel, streamOptions: settings.stream, cutoff, }; return { status: "running", checkpoint: { phase: "request", ...request } };}, context);request: gửi, stream, và phân loại
request: async (task, runtime, context) => { const { attempt, compacted, model: ref, thinkingLevel, streamOptions, cutoff } = task.state.checkpoint; const conversationId = runtime.conversationId; await runtime.commit(async (tx) => { const live = await tx.doc(LiveDoc, conversationId); await convertPartial(tx, live, conversationId); live.generation = { attempt }; return undefined; }, context); const model = runtime.models.getModel(ref.provider, ref.modelId); if (model === undefined) return failNoModel(runtime, ref, context); const view = await runtime.context(conversationId, context, { at: cutoff }); let messages = view.messages; await runtime.hooks.each("beforeRequest", async (hook) => { const replaced = await hook({ messages }, runtime, context); if (replaced !== undefined) messages = replaced.messages; }); const options: SimpleStreamOptions = { ...streamOptions, signal: runtime.signal, telemetry: context.telemetry, sessionId: await ensureProviderSessionId(runtime, context), ...(thinkingLevel === "off" ? {} : { reasoning: thinkingLevel }), }; const message = await streamResponse(runtime, model, messages, options, attempt, context); const request = { attempt, compacted, model: ref, cutoff, messages: view.messages }; await classify(runtime, request, message, context);},pi.assistant với stop reason aborted trước khi gửi lại.Hai chi tiết đáng dừng lại. Context được đọc at: cutoff (dòng 220), không phải "transcript hiện tại", nên lần gửi lại sau crash gửi đúng những message đã ghim; entry partial vừa được chuyển đổi nằm sau cutoff, và context derivation cũng loại mọi message assistant có stop reason aborted, error hay deferred khỏi các request sau (context.ts:10, context.ts:222–229). Hook beforeRequest chạy lại ở mỗi lần thử, kể cả lần phục hồi, và kết quả của nó chỉ dùng cho request đó, không được lưu. Test harness-generation-recovery.test.ts:125–163 kiểm tra đúng điều này: sau khi mở lại, transcript là pi.user, pi.assistant (aborted), pi.assistant và request gửi lại chỉ chứa message của người dùng.
Stream: partial được commit tối đa 100 ms một lần
const flush = (): void => { timer = undefined; const partial = pending; pending = undefined; if (partial === undefined || stopped) return; inFlight = (async () => { // Copy synchronously: the provider keeps mutating its partial. const message = copyJson(partial, { omitUndefinedProperties: true }); await runtime.commit(async (tx) => { const live = await tx.doc(LiveDoc, runtime.conversationId); live.generation ??= { attempt }; assignJson(live.generation as Draft<Record<string, JsonValue>>, "message", message); return undefined; }, context); })() .catch((error: unknown) => { // Rejections after an abort mark or close are expected; the committed state stays consistent. if (!runtime.signal.aborted) runtime.report(error); }) .finally(() => { inFlight = undefined; if (pending !== undefined && !stopped) timer = runtime.setTimer(flush, interval); });};try { const events = runtime.models.streamSimple(model, { messages: [...messages] }, options); for await (const event of events) { // A partial without content, such as pi-ai's opening `start` event, shows nothing; a deferred response // never gets past it, so it never leaves a partial. if (event.type === "done" || event.type === "error" || event.partial.content.length === 0) continue; pending = event.partial; if (timer === undefined && inFlight === undefined) timer = runtime.setTimer(flush, interval); } return await events.result();} finally { stopped = true; timer?.(); await inFlight;}finally dừng throttle và chờ commit đang bay, nên không có partial cũ nào rơi vào sau kết quả.Khoảng cách là settings.progress.partialIntervalMs, mặc định 100 ms (agent.ts:34–37). Đây là thoả hiệp giữa độ mượt và số lần ghi: một crash mất tối đa khoảng đó, và host có storage ở xa có thể nới ra, ví dụ 500 ms (README.md:502). Timer dùng runtime.setTimer(), nên invocation kết thúc là throttle dừng theo.
classify: một commit cho mỗi kết cục
classify() trước hết thoát nếu tín hiệu đã bị huỷ (abort mark hoặc đóng: invocation abort hoặc lần chạy sau sẽ xử lý), rồi xử lý deferred bằng một sleep tới pollAt (mặc định 5 giây sau, và luôn muộn hơn lần poll trước, generation.ts:511–531), gọi các hook afterResponse, và rẽ nhánh: toolUse có lời gọi sang vòng tool, stop/length sang trả lời. Nhánh lỗi là nơi retry và compaction gặp nhau:
const settings = runtime.settings;const overflow = message.stopReason === "error" && isContextOverflow(message);const compaction = compactionPolicy(settings, ref);if (overflow && compacted === undefined && compaction.enabled) { const view = await runtime.context(conversationId, context, { at: cutoff }); if (selectCut(view, compaction.keepRecentTokens) !== undefined) { const text = message.errorMessage ?? "Context overflow"; await runtime.commit(async (tx): Promise<Next> => { const live = await tx.doc(LiveDoc, conversationId); await appendAssistant(tx, conversationId, message); delete live.generation; const child = await createCompaction(tx, conversationId, { reason: "overflow" }, runtime.taskId); const checkpoint = { phase: "prepare", attempt, compacted: child, overflow: text } as const; return { status: "waiting", checkpoint, on: [child], policy: "allSettled" }; }, context); return; }}const policy = settings.retry;// An overflow is never retried: only a compaction can make the next request fit.const retry = message.stopReason === "error" && !overflow && isRetryableAssistantError(message) && policy.enabled && attempt <= policy.maxRetries;const until = retry ? runtime.now() + retryDelayMs(policy, attempt) : 0;await runtime.commit(async (tx): Promise<Next> => { // Read before the first table write; only a failure uses it. const failure = retry ? undefined : await prepareFailure(tx, runtime); const live = await tx.doc(LiveDoc, conversationId); await appendAssistant(tx, conversationId, message); if (failure === undefined) { live.generation = { attempt, retry: { at: until, error: message.errorMessage ?? "" } }; const checkpoint = { phase: "retry", attempt, ...(compacted === undefined ? {} : { compacted }), until, } as const; return { status: "sleeping", checkpoint, until }; }Đi qua từng nhánh. Tràn context (pi-ai isContextOverflow()), khi compaction bật, generation chưa compaction lần nào (compacted rỗng) và phép chọn điểm cắt tìm được chỗ cắt: một commit nối entry lỗi, tạo compaction do generation sở hữu với lý do overflow, rồi chờ nó (allSettled) với checkpoint prepare mang theo compacted và văn bản lỗi. Khi prepare chạy lại, nó kiểm tra compaction có hoàn tất với một entryId không; không thì thất bại model_error (generation.ts:141–146). Lần tràn thứ hai thì không compaction nữa, vì compacted đã có giá trị. Lỗi tạm thời (isRetryableAssistantError()) mà còn lượt (attempt <= maxRetries, mặc định 3 lần thử lại với delay gốc 2 giây, agent.ts:20–25): nối entry lỗi và ngủ tới now + retryDelayMs(policy, attempt). Phase retry sau đó quay lại prepare với attempt kế, nên thay đổi agent hay settings trong lúc chờ có hiệu lực (generation.ts:238–250). Mọi trường hợp khác: nối entry lỗi, kết thúc run với input unanswered lý do model_error, và settings.afterFailedRun quyết định số phận của các input xếp hàng phía sau (generation.ts:411–428).
Mọi câu trả lời, kể cả lỗi và partial bị huỷ, đi qua appendAssistant(), hàm nối entry và cộng usage của message vào document pi.usage trong cùng commit, theo khoá provider/model (generation.ts:739–750, usage.ts:24–39). Vì vậy lần thử thất bại cũng được tính tiền, còn lần thử bị cắt ngang chưa có phản hồi thì không có gì để tính.
answer: final boundary và onYield
async function answer(runtime: Runtime, message: AssistantMessage, context: Context): Promise<void> { let continuation: UserInput | undefined; await runtime.hooks.each("onYield", async (hook) => { if (continuation !== undefined) return; continuation = (await hook(message, runtime, context))?.continue; }); const conversationId = runtime.conversationId; await runtime.commit(async (tx): Promise<Next> => { // Queue modes are read on the Session line, when the boundary is decided. const boundary = await prepareBoundary(tx, conversationId, runtime.settings); const live = await tx.doc(LiveDoc, conversationId); const entry = await appendAssistant(tx, conversationId, message); const result: Next = { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } }; const { users, reset } = await applyBoundary(tx, boundary, "final", runtime.now()); if (continuation !== undefined && users.length === 0 && !reset) { const user = { role: "user", content: continuation, timestamp: runtime.now() } as const; await tx.appendEntry(UserEntry, conversationId, { model: [user] }); handOver(live, runtime.taskId, await createGeneration(tx, conversationId)); delete live.generation; return result; } endRun(tx, live, runtime.taskId, { status: "done", answer: entry.id }); if (users.length > 0) await startRun(tx, conversationId, live, users); return result; }, context);}onYield chỉ được dùng khi boundary không chọn input nào của người dùng và không có reset; một continuation bị bỏ qua không được thử lại."Final boundary" là điểm đặt các mục đang xếp hàng trong inbox sau một câu trả lời (xem mục inbox). Nếu boundary chọn được follow-up, startRun() tạo một generation mới cho chúng ngay trong commit đó (generation.ts:752–769).
Vòng tool: generation sở hữu, chờ, rồi trao run
const conversationId = runtime.conversationId;const messages = request.messages ?? (await runtime.context(conversationId, context, { at: request.cutoff })).messages;const offered = new Set(getCurrentTools(messages).map((tool) => tool.name));// Read as the round starts; a tool is resolved as its tool task resolves it.const tools = (await runtime.agent(context)).tools;const sequential = runtime.settings.toolExecution === "sequential" || calls.some( (call) => offered.has(call.name) && tools.find((tool) => tool.name === call.name)?.executionMode === "sequential", );await runtime.commit(async (tx): Promise<Next> => { const live = await tx.doc(LiveDoc, conversationId); const entry = await appendAssistant(tx, conversationId, message); const slots: ToolSlot[] = []; const tools: TaskId<ToolTaskResult>[] = []; const pending: string[] = []; for (const call of calls) { if (!offered.has(call.name)) { const unavailable = harnessError("tool_unavailable", `Tool ${call.name} is not available`); const result = await appendToolResult(tx, conversationId, call, unavailable, { timestamp: runtime.now() }); slots.push({ callId: call.id, name: call.name, status: "done", entry: result.id }); continue; } if (sequential && tools.length > 0) { pending.push(call.id); slots.push({ callId: call.id, name: call.name, status: "pending" }); continue; } const taskId = await createToolTask(tx, runtime, entry.id, call.id); tools.push(taskId); slots.push({ callId: call.id, name: call.name, taskId, status: "pending" }); } delete live.generation; live.tools = slots; const checkpoint = { phase: "tools", assistant: entry.id, tools, pending } as const; return { status: "waiting", checkpoint, on: tools, policy: "allSettled" };}, context);getCurrentTools(), dòng 639), không phải từ agent hiện tại. Lời gọi tới tool không được mời nhận kết quả tool_unavailable ngay, không có task.Vòng là tuần tự khi settings.toolExecution là sequential hoặc bất kỳ tool nào được gọi khai báo executionMode: "sequential"; khi đó chỉ lời gọi đầu có task, các lời gọi còn lại nằm trong pending của checkpoint và phase tools tạo từng cái một (generation.ts:264–282). Khi mọi task tool đã terminal, phase tools kết thúc vòng:
await runtime.hooks.each("afterTools", (hook) => hook(assistant, results, runtime, context));// Every call of the round, including those answered without a task, must ask to terminate.const terminate = slots.length > 0 && slots.every((slot) => slot.taskId !== undefined && controls.get(slot.taskId)?.terminate === true);const added = [...controls.values()].flatMap((control) => control?.addTools ?? []);// The last handoff in call order wins.const handoff = [...controls.values()].findLast((control) => control?.handoff !== undefined)?.handoff;await runtime.commit(async (tx): Promise<Next> => { const boundary = await prepareBoundary(tx, conversationId, runtime.settings); if (added.length > 0) await addTools(tx, conversationId, added); const live = await tx.doc(LiveDoc, conversationId); const now = runtime.now(); if (terminate || handoff !== undefined) { if (handoff !== undefined) { const message = { role: "user", content: handoff, timestamp: now } as const; const entry = await tx.appendEntry(ResetEntry, conversationId, { head: "self", model: [message] }); boundary.head = entry.id; } const { users } = await applyBoundary(tx, boundary, "final", now); endRun(tx, live, runtime.taskId, { status: "done", answer: assistant }); if (users.length > 0) await startRun(tx, conversationId, live, users); } else { const { users, reset } = await applyBoundary(tx, boundary, "postTools", now); if (reset) { // The queued reset cut the run's context before an answer. endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "reset" }); if (users.length > 0) await startRun(tx, conversationId, live, users); } else { delete live.tools; delete live.nestedTools; if (live.run?.taskId === runtime.taskId) live.run.inputs.push(...users); handOver(live, runtime.taskId, await createGeneration(tx, conversationId)); } } return { status: "terminal", outcome: { status: "completed", result: { entryId: assistant } } };}, context);postTools gia nhập input của run đang chạy. Dòng 732: handOver() chuyển pi.live.run.taskId sang generation mới; các input vẫn placed cho tới câu trả lời cuối.Các control trong kết quả tool được áp dụng ở đây (spec.md:3586–3594): terminate chỉ có hiệu lực khi mọi lời gọi của vòng đều yêu cầu, giống vòng lặp agent của pi; handoff nào cũng đủ, lấy cái cuối theo thứ tự lời gọi, và nối một entry pi.reset với head: "self", đúng như reset(handoff); addTools ghi tên tool vào pi.agent để request sau mời chúng (agent.ts:93–118). Generation kết thúc completed với entryId của câu trả lời gọi tool; run thì sống tiếp trong generation mới.
Handler abort() của generation chỉ chạy sau khi các task tool của nó đã terminal (thứ tự từ dưới lên). Nó huỷ phản hồi deferred nếu đang poll, chuyển partial đã commit thành entry, nối kết quả aborted cho các lời gọi tuần tự chưa bắt đầu, và settle input unanswered lý do aborted (generation.ts:284–315).
Bốn hook của generation
| Hook | Khi nào | Cách ghép nhiều handler |
|---|---|---|
beforeRequest | Trước mỗi lần thử, kể cả phục hồi; chỉ áp cho request đó | Chuỗi thay thế: handler sau nhận message của handler trước |
afterResponse | Mọi message kết thúc của provider, trước khi phân loại | Tất cả quan sát |
onYield | Câu trả lời cuối; { continue } nối một tin nhắn người dùng và tiếp tục run | Continuation đầu tiên thắng |
afterTools | Khi mọi tool của vòng đã terminal | Tất cả quan sát |
Bảng trên tóm tắt spec.md:2912–2920. Lỗi thường trong hook được báo qua onReport và handler kế vẫn chạy; lỗi huỷ thì luôn lan ra (scheduler.ts:2096–2113). Spec cũng nói rõ: hook chạy ngoài dòng commit, nên một crash trước commit tiêu thụ kết quả của nó có thể làm hook chạy lại (spec.md:2909–2910).
pi.tool: ý định trước, hiệu ứng sau#
Mỗi lời gọi tool là một task pi.tool riêng. Với lời gọi do mô hình phát ra, task thuộc về generation và input chỉ gồm ID của entry câu trả lời cùng callId: lời gọi được đọc lại từ entry đó, nên bản ghi terminal (được giữ mãi) vẫn nhỏ. Lời gọi lồng nhau (nested) phải mang lời gọi trong input vì không entry nào chứa nó.
/** * A model-issued call, read from its assistant entry, or a nested call a running tool made through `executeTool()`, * whose call the input carries because no entry holds it. */export type ToolTaskInput = | { kind: "model"; assistant: EntryId; callId: string } | { kind: "nested"; parent: TaskId; parentCallId: string; key: string; call: ToolCall; /** `false`: commit no running output, details, or diagnostics to the slot; the result still gets them. */ progress?: false; };export type ToolTaskCheckpoint = | { phase: "call" } /** Durable intent: the final arguments and the replay policy recorded before execution. */ | { phase: "execute"; arguments: JsonObject; replay: "safe" | "unsafe" };/** * A model-issued call's result is its transcript entry; a nested call's result is in its caller's * `NestedCallDoc`, so the receipt stays small and the result retires with the caller. A version 1 task that was * already holding its outcome as `completing` when the Harness upgraded finishes with a version 1 result, * `{ entryId, control? }` without `kind`; treat a result without `kind` as a model-issued call's. */export type ToolTaskResult = { kind: "model"; entryId: EntryId; control?: ToolControl } | { kind: "nested" };execute: nó lưu đối số cuối cùng (sau khi sửa, hook và ép kiểu) và chính sách replay trước khi tool chạy.Phía tác giả tool, mọi thứ khai báo trên ToolRegistration, mở rộng Tool của pi-ai:
export type ToolRegistration< TParameters extends TSchema = TSchema, TDetails extends JsonValue = JsonValue,> = Tool<TParameters> & { /** Whether an interrupted execution may rerun on recovery. Default `unsafe`. */ readonly replay?: "safe" | "unsafe"; /** Default: the settings' `toolExecution`. One sequential call makes its whole round sequential. */ readonly executionMode?: ToolExecutionMode; /** * Repair arguments models commonly get wrong before validation, such as a JSON string where an array belongs. Must be * pure and must not mutate `args`: it runs again when a call is retried before its intent is recorded. Its result is * still validated against `parameters`. */ prepareArguments?(args: unknown): Static<TParameters>; /** * Schema of `structuredOutput`, which programs that call the tool receive. Omitted: they receive the result's bounded * output, success or error, as `NestedToolExecutionResult.structuredOutput` describes. */ readonly structuredOutputSchema?: TSchema; /** * Who may call the tool: the model, other tools through `executeTool()`, or both (default). A code mode tool is * `["model"]`, so scripts cannot start scripts; tools behind code mode, such as MCP tools, can be `["tools"]`, so the * model is never offered them. */ readonly callers?: readonly ("model" | "tools")[]; /** * Not offered to the model until loaded: the model gets the tool only when `modelTools` names it, in its array or its * `add`, which `control.addTools` (from a tool such as `tool_search`) writes. Other tools can call it any time. */ readonly deferred?: true; readonly outputLimits?: { readonly maxBytes?: number; readonly maxLines?: number; readonly retain?: "head" | "tail"; }; execute( args: Static<TParameters>, api: ToolExecutionApi<TDetails>, context: Context, ): Promise<ToolExecutionResult<TDetails>>;};replay thì là unsafe. Không khai báo outputLimits thì là 50 KiB, 2 000 dòng, giữ phần đầu (spec.md:3152–3155).Phase call: phân giải, kiểm tra, hook, ghi ý định, chạy
call: async (task, runtime, context) => { const call = await readCall(runtime, task.input, context); const tool = await resolveTool(runtime, task.input, call.name, context); if (tool === undefined) { const error = harnessError("tool_unavailable", `Tool ${call.name} is not available`); return settle(runtime, task.input, call, COMPLETED, () => error, context); } const prepared = prepare(tool, call.arguments as JsonObject); const checked = "error" in prepared ? prepared : validate(tool, call, prepared.args); if ("error" in checked) return settle(runtime, task.input, call, COMPLETED, () => invalid(checked.error), context); let args = checked.args; let block: string | undefined; await runtime.hooks.each("beforeTool", async (hook) => { if (block !== undefined) return; try { const decision = await hook({ ...call, arguments: args }, runtime, context); if (decision?.block !== undefined) block = decision.block; else if (decision?.arguments !== undefined) args = decision.arguments; } catch (error) { if (runtime.signal.aborted) throw error; block = errorMessage(error); } }); if (block !== undefined) { const blocked = harnessError("blocked", `Tool call blocked: ${block}`); return settle(runtime, task.input, call, COMPLETED, () => blocked, context); } const validated = validate(tool, call, args); if ("error" in validated) return settle(runtime, task.input, call, COMPLETED, () => invalid(validated.error), context); // Strict JSON: repair may set optional properties to undefined, which the slot and checkpoint cannot hold. const final = copyJson(validated.args, { omitUndefinedProperties: true }) as JsonObject; await runtime.commit(async (tx) => { const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId); if (slot !== undefined) slot.status = "running"; // A nested slot shows the arguments the call runs with, which repair, hooks, and coercion may have changed. // Compared with the input, which the slot got at admission: reading the draft would track every leaf. const changed = task.input.kind === "nested" && !jsonEqual(task.input.call.arguments as JsonValue, final); if (changed && slot !== undefined && "parentTaskId" in slot) slot.arguments = final; const intent = { phase: "execute", arguments: final, replay: tool.replay ?? "unsafe" } as const; return { status: "running", checkpoint: intent }; }, context); await run(runtime, task.input, call, tool, final, context);},Đi theo thứ tự. Tên tool được tra trong agent.tools (lời gọi của mô hình) hoặc agent.callable (lời gọi lồng nhau) của lần phân giải agent trong phase này (tool.ts:214–223); không thấy thì trả tool_unavailable. prepareArguments sửa các lỗi đối số mô hình hay mắc (như mảng gửi dưới dạng chuỗi JSON), rồi đối số được kiểm tra bằng validateToolArguments() của pi-ai. Chuỗi hook beforeTool có thể thay đối số hoặc chặn; chặn đầu tiên thắng, và hook ném lỗi thì lời gọi bị chặn với nội dung lỗi (dòng 154–157). Đối số được kiểm tra lại sau hook. Rồi đến commit then chốt ở dòng 168–177: slot của lời gọi trong pi.live.tools chuyển sang running và checkpoint thành { phase: "execute", arguments, replay }. Chỉ sau đó run() mới dựng môi trường và gọi execute().
Phase execute: chỉ phục hồi mới tới đây
/** Recovery after intent: rerun only when the stored and the current policy both say `safe`. */execute: async (task, runtime, context) => { const { arguments: args, replay } = task.state.checkpoint; const call = await readCall(runtime, task.input, context); const tool = await resolveTool(runtime, task.input, call.name, context); if (replay === "safe" && tool?.replay === "safe") { // The rerun reports from scratch; clear what the interrupted attempt published. await runtime.commit(async (tx) => { const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId); if (slot !== undefined) clearProgress(slot); return undefined; }, context); return run(runtime, task.input, call, tool, args, context); } const message = `Tool ${call.name} was interrupted and may have partially run`; // `failed` records cancellation intent, so the call's owned conversations, left unsupervised, are aborted. const ending = { status: "failed", message } as const; await settle(runtime, task.input, call, ending, (slot) => fromSlot(slot, "interrupted", message), context);},beforeTool. Không chạy lại thì kết quả là lỗi interrupted dựng từ phần đầu ra đã commit trong slot.Quy tắc "cả hai đều safe" có hai chiều: khai báo hiện tại là unsafe có thể phủ quyết một replay đã được lưu là safe, nhưng khai báo hiện tại là safe không nâng cấp được một intent đã lưu là unsafe; tool không còn phân giải được (extension chưa cài lại, bị bỏ chọn) thì bị coi là unsafe (spec.md:3601–3607). Test chạy cả ba tổ hợp và đếm số lần chạy (harness-tools-recovery.test.ts:136–163).
Kết quả interrupted kết thúc task với trạng thái failed, không phải completed (dòng 196). Lý do có chủ đích: failed là ý định huỷ, nên mọi conversation mà lời gọi sở hữu (ví dụ subagent của nó), giờ không còn ai giám sát, bị abort theo. Một kết quả isError mà tool tự trả về thì vẫn completed và việc con của nó sống tiếp (spec.md:4356–4365). Thông điệp mà mô hình đọc được lấy từ test:
expect(result).toMatchObject({ isError: true, details: { run: 1 } });expect(text(result)).toBe( "run 1\n|<harness>\n[error] Tool work was interrupted and may have partially run\n</harness>",);Chạy tool: đầu ra, giới hạn, thời gian
api.output(chunk) đẩy văn bản vào một OutputBuffer có giới hạn (của Chord) và đánh dấu tiến độ; các commit tiến độ ghi phần đầu ra được giữ lại vào slot. Nhịp commit thích nghi: thay đổi đầu tiên sau một lúc rảnh được commit ngay, sau đó mỗi commit đẩy lần kế ra ít nhất outputIntervalMs (mặc định 100 ms) và thêm thời gian theo kích thước đã ghi ở 100 KiB/s (spec.md:3307–3317; mã ở tool.ts:479–532). Vì thế một crash mất tối đa một cửa sổ throttle đầu ra, và kết quả interrupted mang đúng phần đã commit đó. Phần cuối của run():
let result: ToolExecutionResult; let ending = COMPLETED; // Execution time of this attempt; a rerun after recovery measures only itself. let durationMs: number | undefined; try { env = await runtime.env(context); const startedAt = performance.now(); try { result = await tool.execute(args, api, context); } finally { durationMs = Math.round(performance.now() - startedAt); } } catch (error) { if (runtime.signal.aborted) { ended = true; for (const waiter of await progress.stop()) waiter.reject(error); throw error; } result = { isError: true, diagnostics: [toolDiagnostic("tool_error", errorMessage(error))] }; // A throw, from `execute()` or from building the environment, ends the task `failed`, which cancels what the call // owned; it no longer supervises it. The error text is already in the result. ending = { status: "failed", message: `Tool ${call.name} threw` }; } ended = true; // No admission starts after `ended`; let those underway commit, so settlement aborts and lists their calls. await Promise.allSettled(admissions); reported.output.end(); // Details still waiting for a progress commit settle with the terminal commit, the final flush. const pending = await progress.stop(); try { const settled = await finalResult(runtime, input, call, tool, result, reported, context); await settle(runtime, input, call, ending, () => settled, context, durationMs); } catch (error) { for (const waiter of pending) waiter.reject(error); throw error; } for (const waiter of pending) waiter.resolve();}Môi trường được dựng bằng HarnessOptions.env cho mỗi lần chạy, từ cwd hiện tại của conversation, nên một lần chạy lại sau phục hồi có thể thấy thư mục khác lần đầu (test ở harness-tools-recovery.test.ts:165–211). durationMs đo bằng đồng hồ đơn điệu, chỉ cho lần thử này.
Ba kênh kết quả và structuredOutput
Kết quả có ba kênh: output cho mô hình, structuredOutput cho chương trình gọi tool (lời gọi lồng nhau, script code mode), và details cho UI (types.ts:155–175). Transcript chỉ lưu output và details. Trước khi settle, finalResult() lấy phần đầu ra giữ lại làm output nếu tool không trả, chạy chuỗi afterTool, giới hạn văn bản, rồi kiểm tra structuredOutput với structuredOutputSchema (tool.ts:540–603, tool.ts:607–628). Giá trị sai hợp đồng làm lời gọi lồng nhau thành kết quả lỗi với chẩn đoán invalid_structured_output; với lời gọi của mô hình (vốn không bao giờ thấy kênh này) giá trị chỉ bị bỏ và host được báo qua onReport. Mọi chẩn đoán (diagnostic) được nối vào cuối nội dung thành một khối <harness>, nên message lưu trong transcript đúng bằng thứ mô hình đã đọc, còn danh sách có cấu trúc nằm trong data.diagnostics của entry (tool.ts:885–913).
settle(): dọn lời gọi con, rồi một commit kết quả
// A crash in between leaves the call in `execute`: a safe rerun reattaches to the calls, an interruption ends here.// Every live nested call the task owns, also those an earlier attempt made and this one never called again.const nestedIds = (await runtime.ownedTasks(context)) .filter((record) => record.kind === ToolTask.definition.name && (record.input as ToolTaskInput).kind === "nested") .map((record) => record.id);// Read before the settling commit: no admission starts after `ended`, and those underway were awaited.const madeNested = (await runtime.memo(MADE_NESTED_CALLS, context)) === true;// Mark them all before waiting for any: one may run until a sibling is cancelled.await Promise.all(nestedIds.map((id) => runtime.abortOwned(id, context)));await runtime.commit(async (tx) => { const conversationId = runtime.conversationId; const live = await tx.doc(LiveDoc, conversationId); const slot = toolSlot(live, runtime.taskId); const result = build(slot); let settled: ToolTaskResult; } else { const timing = { timestamp: runtime.now(), durationMs }; const entry = await appendToolResult(tx, conversationId, call, result, timing); if (slot !== undefined && !("parentTaskId" in slot)) { finishSlot(slot); slot.entry = entry.id; } // Tools build control objects freely; drop keys set to undefined so the task result is strict JSON. const control = result.control === undefined || ending.status !== "completed" ? {} : { control: copyJson(result.control as JsonValue, { omitUndefinedProperties: true }) as ToolControl }; settled = { kind: "model", entryId: entry.id, ...control }; } // A call that made no nested calls, in any attempt, has no slots below it; skipping the scan keeps many leaves // linear. Committed before the read: no nested call is admitted after `ended`. if (madeNested) removeNestedSlots(live, runtime.taskId); if (ending.status === "aborted") return { status: "terminal", outcome: { status: "aborted", result: settled } }; if (ending.status === "failed") { const error = { message: ending.message }; return { status: "terminal", outcome: { status: "failed", error, result: settled } }; } return { status: "terminal", outcome: { status: "completed", result: settled } };}, context);ownedTasks(), tức là cả những lời gọi một lần thử trước đã tạo mà lần chạy lại không gọi nữa. Tất cả bị đánh dấu abort trước khi chờ cái nào, vì một lời gọi có thể chạy mãi cho tới khi anh em của nó bị huỷ.Các control (terminate, handoff, addTools) chỉ được ghi vào kết quả khi task kết thúc completed (dòng 682–685); generation đọc chúng ở phase tools. usage của kết quả được cộng vào pi.usage.tools[tên tool] trong cùng commit.
Hook của tool và memo: quyết định không lặp lại sau crash
Hook beforeTool (chặn hoặc sửa đối số) và afterTool (thay kết quả) chạy trong invocation của task tool, ngoài dòng commit. Harness không tự lưu quyết định của hook. Thứ nó lưu là hệ quả: đối số cuối cùng trong ý định. Vì vậy sau khi ý định đã được ghi, phục hồi không bao giờ gọi lại beforeTool (spec.md:3456–3464). Nhưng crash xảy ra trước ý định thì phase call chạy lại từ đầu và hook được hỏi lần nữa. Một hook mà câu trả lời phải ổn định, ví dụ xin người dùng phê duyệt, dùng api.memo(name, candidate) (mẫu approval:decision ở harness-tools.test.ts:810–816): HookApi chính là runtime của task, nên memo nằm trong TaskRecord.memos, người ghi đầu thắng, và tồn tại qua crash (spec.md:2922–2923). Test chứng minh cả hai nửa:
it("reruns beforeTool when interrupted before intent, and executes once", async () => { const path = await sqlitePath(); const setup = chatSetup(); let runs = 0; addTool( setup.registry, tool("work", async () => { runs++; return { output: [] }; }), ); const reached = deferred(); let asked = 0; const decisions: string[] = []; addHooks(setup.registry, ToolTask, { beforeTool: async (_call, api, callContext) => { asked++; // A durable first-writer-wins decision survives the rerun. decisions.push(await api.memo("test:decision", `attempt ${asked}`, callContext)); if (asked === 1) { reached.resolve(); await aborted(callContext.abortSignal!); } return undefined; }, }); setup.faux.setResponses([call("work"), DONE]); let opened = await open(path, setup); const id = (await opened.root.submit({ type: "input", content: "go" }, context)).id; await reached.promise; await opened.harness.close(context); opened = await open(path, setup); expect((await (await opened.harness.submission(id, context))!.wait(context)).status).toBe("done"); expect([asked, runs]).toEqual([2, 1]); expect(decisions).toEqual(["attempt 1", "attempt 1"]); await opened.harness.close(context);});close(); với việc phase chạy lại từ đầu thì close và crash như nhau, chỉ khác ở bộ đếm restart. Hook và task dùng chung một không gian tên memo, nên tác giả hook nên đặt tiền tố cho tên (ở đây là test:decision, spec.md:5519–5520).Lời gọi lồng nhau, callers và tool hoãn nạp#
Một script code mode hay một cầu nối MCP gọi các tool khác từ bên trong một tool. Nếu những lời gọi đó là lời gọi hàm bình thường, crash làm mất dấu cái nào đã chạy, lần chạy lại sẽ chạy lại tất cả, và chúng không có hook, giới hạn đầu ra hay hiển thị riêng. Pi Durable biến mỗi lời gọi như vậy thành một task pi.tool riêng, do task của tool gọi sở hữu, qua api.executeTool():
executeTool: async (name, toolArgs, callContext, options = {}) => { assertLive(); const key = options.key ?? String(++sequence); if (options.key !== undefined) checkKey(options.key); // The memo first, once per attempt; a failed one is tried again by the next call. madeNestedMemo ??= runtime.memo(MADE_NESTED_CALLS, true, callContext).catch((error: unknown) => { madeNestedMemo = undefined; throw error; }); // Registered now, so cleanup waits for it. const admission = madeNestedMemo.then(() => startNestedCall( runtime, call.id, name, toolArgs, { key, progress: options.progress, abandonOnRestart: !resumes }, callContext, ), ); admissions.push(admission); const id = await admission; const settled = await runtime.waitForTask(id, callContext); const stored = (await runtime.snapshot(NestedCallDoc, runtime.taskId, key, callContext))?.result; // A copy the tool may change; a nested call the scheduler faulted or orphaned stored none. return stored === undefined ? fallbackResult(id, name, settled.state.outcome) : (copyJson(stored as unknown as JsonValue) as unknown as NestedToolExecutionResult);},<callId>/<key>. Khoá tường minh không được là số nguyên dương, để không bao giờ trùng khoá mặc định (tool.ts:939–949).await runtime.commit(async (tx) => { // Opened, or created empty for a key not made yet. const state = await tx.doc(NestedCallDoc, runtime.taskId, key, null); const existing = state.taskId; if (existing !== undefined) { const input = (await tx.task(existing))?.input as ToolTaskInput | undefined; const same = input?.kind === "nested" && input.call.name === name && jsonEqual(input.call.arguments as JsonValue, call.arguments as JsonValue); if (!same) throw new Error(`Nested call ${call.id} was already made with another tool or other arguments`); id = existing as TaskId<ToolTaskResult>; replayed = state.result !== undefined; return undefined; } const input: ToolTaskInput = { kind: "nested", parent: runtime.taskId, parentCallId, key, call, ...(options.progress === false ? { progress: false } : {}), }; const created = await tx.createTask(ToolTask, input, { ownership: { kind: "task", taskId: runtime.taskId }, abandonOnRestart: options.abandonOnRestart, });abandonOnRestart.Mỗi lời gọi lồng nhau là một thành viên của NestedCallDoc, một họ document (document family) có phạm vi task của tool gọi, khoá theo khoá lời gọi, chứa { taskId, result? }. Một document cho mỗi lời gọi, nên nhận một lời gọi mới không phải viết lại các lời gọi khác; tất cả nghỉ hưu cùng tool gọi (tool.ts:101–114). Kết quả lưu ở đó đúng bằng thứ executeTool() trả về, không có output dành cho mô hình, nên một ảnh hay đuôi đầu ra bash chỉ được lưu một lần. Từ đó suy ra hai hành vi khi restart:
- Tool gọi replay-safe chạy lại và gọi theo cùng thứ tự (hoặc cùng khoá): mỗi
executeTool()gắn lại vào task đã có, đã xong hay còn chạy, thay vì tạo mới. Đây không phải exactly-once: một lời gọi con bị crash cắt ngang trước khi commit kết quả theo chính sách replay của nó (spec.md:3201–3209). Test: tool gọi chạy hai lần, con safe chạy hai lần, con unsafe một lần và trảinterrupted, tổng cộng vẫn chỉ có hai task con (harness-nested-tools-restart.test.ts:400–463). - Tool gọi không replay-safe không bao giờ chạy lại, nên các lời gọi con chưa xong của nó bị bỏ (abandoned) trước khi kịp chạy lại: con đã ghi ý định nhận
interrupted, con chưa ghi nhậnabandoned(harness-nested-tools-restart.test.ts:93–166).
Ai được gọi tool nào
Ba khái niệm liên quan: callers trên tool (mô hình, tool khác, hay cả hai), modelTools trên agent của conversation (thu hẹp những tool được mời mô hình), và deferred (tool chỉ được mời sau khi được nạp). Phép phân giải agent ghép chúng lại:
const enabled = filterTools([...composed.values()], state?.tools);const callableBy = (caller: "model" | "tools") => (tool: Tool) => tool.callers === undefined || tool.callers.includes(caller);const modelCallable = enabled.filter(callableBy("model"));const tools = offeredTools(modelCallable, state?.modelTools);const offered = new Set(tools);const deferred = modelCallable.filter((tool) => tool.deferred === true && !offered.has(tool));const callable = enabled.filter(callableBy("tools"));Lời gọi của mô hình được tra trong tools, lời gọi lồng nhau trong callable. Cách dùng điển hình: tool codemode là callers: ["model"] nên script không khởi chạy script; các tool MCP có thể khai báo callers: ["tools"] để mô hình không bao giờ được mời chúng mà chỉ gọi qua script (README.md:333–347). Tool hoãn nạp được nạp bởi tool tool_search, bản sao của coding agent (chương Codemode, MCP và tool search):
// Searching changes what the model is offered; scripts have their own `searchTools()`.callers: ["model"],replay: "safe",async execute({ query, limit }, api, context) { if (query.trim() === "") throw new Error("query must not be empty"); const max = limit ?? DEFAULT_TOOL_SEARCH_LIMIT; if (!Number.isInteger(max) || max <= 0) throw new Error("limit must be a positive integer"); const candidates = (await api.agent(context)).deferred; const documents = candidates.map((tool) => ({ name: tool.name, text: searchText(tool) })); const loaded = rankDocuments(query, documents, max); const text = loaded.length === 0 ? "No matching tools found." : `Loaded ${loaded.length} tool${loaded.length === 1 ? "" : "s"}. They are available from your next call:\n${loaded .map((name) => { const description = candidates.find((tool) => tool.name === name)?.description ?? ""; return `- ${name}: ${description.trim().split(/\r?\n/)[0]}`; }) .join("\n")}`; const details: ToolSearchToolDetails = { loaded }; return { output: [{ type: "text", text }], details, ...(loaded.length === 0 ? {} : { control: { addTools: loaded } }), };},addTools được áp ở phase tools của generation, ghi tên vào modelTools.add của pi.agent.Vì pi.agent là document rewindable và fork theo asOf, tool đã nạp thuộc về từng nhánh và vẫn được mời sau compaction. Tool đã nạp đứng sau các tool khác theo thứ tự nạp, nên một lần nạp chỉ nối thêm khai báo vào transcript và tiền tố đã cache ở provider vẫn giữ (spec.md:3270–3281), đúng lập luận của bài "Prompt Caching In Agents" về nạp tool cộng dồn.
Inbox: steer, follow-up và write được đặt ở đâu#
Người dùng gõ thêm trong lúc agent đang làm. Tin nhắn đó vào transcript ở đâu? Vị trí không tuỳ tiện: kết quả tool phải đứng ngay sau lời gọi của nó, và prompt cache của provider là một tiền tố chính xác. Pi Durable xếp input và write gửi tới một conversation bận (trừ write chỉ để hiển thị, xem bên dưới) vào document pi.inbox rồi đặt chúng ở hai loại ranh giới (boundary): postTools sau một vòng tool, và final sau một câu trả lời (spec.md:2579–2592).
| Boundary | write | steer | follow-up |
|---|---|---|---|
postTools (sau vòng tool) | tất cả | cái đầu hoặc tất cả, theo steeringMode | không |
final (sau câu trả lời) | tất cả | cái đầu hoặc tất cả | cái đầu hoặc tất cả, theo followUpMode |
export async function applyBoundary( tx: Tx, boundary: Boundary, at: "postTools" | "final", now: number,): Promise<BoundaryResult> { const { conversationId, inbox } = boundary; const items = inbox.items; const reset = hasQueuedReset(items); const final = at === "final" || reset; const pick = (mode: "steer" | "followUp", queueMode: QueueMode): number[] => { const indexes = items.flatMap((item, index) => (item.mode === mode ? [index] : [])); return queueMode === "all" ? indexes : indexes.slice(0, 1); }; const writes = items.flatMap((item, index) => (item.mode === "write" ? [index] : [])); const users = [...pick("steer", boundary.steeringMode), ...(final ? pick("followUp", boundary.followUpMode) : [])]; users.sort((a, b) => a - b); // `appendEntry()` copies the drafts' values; the items are removed only afterwards. for (const index of writes) { const item = items[index] as Draft<Extract<InboxItem, { mode: "write" }>>; const draft = item.entry as unknown as EntryDraft; if (isStale(boundary, draft)) { tx.settleSubmission(item.id, { status: "unanswered", reason: "stale" }); continue; } const entry = await tx.appendEntry(conversationId, draft); if (draft.head !== undefined) boundary.head = draft.head === "self" ? entry.id : draft.head; tx.placeSubmission(item.id, entry.id); } const placed: SubmissionId[] = []; for (const index of users) { const item = items[index] as Draft<Extract<InboxItem, { mode: "steer" | "followUp" }>>; const entry = await appendInput(tx, conversationId, item.content as UserInput, item.entry as InputEntry, now); tx.placeSubmission(item.id, entry.id); placed.push(item.id); } const removed = [...writes, ...users].sort((a, b) => b - a); for (const index of removed) items.splice(index, 1); return { users: placed, reset };}postTools thành final (dòng 78–79).- Steer (
whenBusy: "steer") được đặt sau vòng tool hiện tại và gia nhập run đang chạy: ID của nó được nối vàopi.live.run.inputs(dòng 731 củafinishToolRound()). Mặc định mỗi boundary chỉ đặt một steer (one-at-a-time, agent.ts:78–79). - Follow-up (mặc định) được đặt khi run trả lời, và mở run kế (
startRun()tronganswer()). - Write chỉ có
kindvàdata(khôngmodel,head,edits) không đổi gì mô hình thấy, nên được nối ngay cả khi đang bận, không qua inbox, trừ khi đứng sau một reset đang xếp hàng (submissions.ts:220–228). Write cómodel,headhayeditschờ boundary như một steer.
Một write mang head trỏ về trước điểm bắt đầu của dải context hiện hành là "cũ" (stale): đặt nó sẽ mang lại phần lịch sử mà một head mới hơn đã cắt, nên nó được settle unanswered với lý do stale (inbox.ts:144–147). Bản tóm tắt compaction chính là head write, nên quy tắc này sắp xếp luôn các compaction chạy song song, như mục sau cho thấy. Khi run thất bại, các mục xếp hàng ở yên trong inbox tới input hay write kế tiếp, trừ khi settings.afterFailedRun nói khác (spec.md:2559–2566).
Compaction là một task#
Compaction (nén ngữ cảnh) ở Pi Durable dùng cùng ý tưởng với coding agent (chương Compaction): tóm tắt một tiền tố cũ của context, nối một entry pi.compaction chứa bản tóm tắt với head trỏ vào entry đầu tiên được giữ nguyên văn; lịch sử thô vẫn ở trong storage. Khác biệt là nó là một task bền pi.compaction, với ba cách khởi động (spec.md:4471–4475):
| Khởi động bởi | Owner | Background | Generation chờ? | Đặt bản tóm tắt |
|---|---|---|---|---|
Conversation.compact() | conversation | không | không | write submission |
| generation, trên ngưỡng nền | conversation | có | không | write submission |
| generation, trên ngưỡng chặn hoặc khi tràn | generation | không | có | nối thẳng |
/** * Create a compaction task with its status in this commit. `owner` is the generation that waits for it (a blocking * compaction); without one it is conversation-owned, and `background` unless it is manual. */export async function createCompaction( tx: Tx, conversationId: ConversationId, input: CompactionInput, owner?: TaskId,): Promise<TaskId<CompactionResult>> { const ownership = owner === undefined ? ({ kind: "conversation" } as const) : { kind: "task" as const, taskId: owner }; const background = owner === undefined && input.reason !== "manual"; const taskId = await tx.createTask(CompactionTask, input, { ownership, conversationId, background }); const status = { taskId, reason: input.reason, blocking: owner !== undefined, attempt: 1 }; addCompactionStatus(await tx.doc(LiveDoc, conversationId), status); return taskId;}Ngưỡng tính theo cửa sổ của mô hình hiện tại. Mặc định reserveTokens 16 384, keepRecentTokens 20 000, backgroundTokens 32 768 (agent.ts:27–32). Trên contextWindow - reserveTokens, request kế phải chờ một compaction chặn; trên contextWindow - reserveTokens - backgroundTokens (khi backgroundTokens > 0), một compaction nền bắt đầu trong khi conversation vẫn làm việc. Cả hai chỉ áp dụng khi phép chọn điểm cắt tìm được chỗ cắt (generation.ts:344–361). modelOverrides cho phép một harness phục vụ cả mô hình 32k lẫn 1M, theo khoá chính xác "provider/modelId" (compaction.ts:261–271). Ước lượng kích thước lấy usage thật của câu trả lời gần nhất sau head marker, cộng ước lượng của các message sau nó (compaction.ts:326–346).
select: async (task, runtime, context) => { const { conversationId } = runtime; const agent = await runtime.agent(context); const settings = runtime.settings; const ref = agent.model; const model = ref === undefined ? undefined : runtime.models.getModel(ref.provider, ref.modelId); if (ref === undefined || model === undefined) return failNoModel(runtime, ref, context); const policy = compactionPolicy(settings, ref); const view = await runtime.context(conversationId, context); const cut = selectCut(view, policy.keepRecentTokens); if (cut === undefined) return complete(runtime, context); const firstKept = view.entries[cut]!.id; const { reason, instructions } = task.input; const compaction = { reason, entries: view.entries.slice(0, cut), messages: summarizedMessages(view, cut), firstKept, ...(instructions === undefined ? {} : { instructions }), }; let decision: { readonly decline: true } | { readonly summary: string } | undefined; await runtime.hooks.each("beforeCompact", async (hook) => { if (decision === undefined) decision = await hook(compaction, runtime, context); }); if (decision !== undefined && "decline" in decision) return complete(runtime, context); if (decision !== undefined) return place(runtime, firstKept, decision.summary, context); const request: SummaryRequest = { attempt: 1, model: ref, thinkingLevel: agent.thinkingLevel, streamOptions: settings.stream, maxTokens: Math.min( Math.floor(0.8 * policy.reserveTokens), model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, ), tail: view.entries.reduce((tail, entry) => (entry.id > tail ? entry.id : tail), firstKept), firstKept, }; await runtime.commit(() => ({ status: "running", checkpoint: { phase: "summarize", ...request } }), context);},beforeCompact: quyết định đầu tiên thắng, { decline: true } hoàn tất không tóm tắt, { summary } đặt bản tóm tắt do hook cung cấp ngay. maxTokens là min(floor(0.8 × reserveTokens), model.maxTokens).Phase summarize dựng lại các message cần tóm tắt từ context tại tail (bất biến, nên đúng là dải mà select đã chọn), tuần tự hoá thành văn bản thuần ([User]: ..., [Tool result]: ... cắt ở 2 000 ký tự, bỏ system message và thinking), và gửi một request riêng không có tool với cacheRetention: "none" (compaction.ts:157–218). Usage của mọi lần thử được cộng vào pi.usage ngay cả khi bản tóm tắt bị bỏ. Lỗi tạm thời ngủ rồi thử lại như generation; thất bại thì ghi pi.live.compactionFailure cho UI.
async function placeSummary( tx: Tx, runtime: Runtime, current: RunningTask<CompactionInput, CompactionCheckpoint, CompactionResult>, live: Draft<LiveState>, firstKept: EntryId, summary: string,): Promise<Next> { removeCompactionStatus(live, runtime.taskId); delete live.compactionFailure; const text = `${SUMMARY_PREFIX}${summary}${SUMMARY_SUFFIX}`; const entry = { kind: CompactionEntry.kind, head: firstKept, model: [{ role: "user", content: [{ type: "text", text }], timestamp: runtime.now() }], data: { reason: current.input.reason }, } satisfies EntryDraft; const result: CompactionResult = current.owner === undefined ? { submissionId: ( await admitSubmission( tx, runtime.conversationId, { type: "write", requestId: `compaction:${runtime.taskId}`, entry }, runtime.now(), runtime.settings, ) ).id, } : { entryId: (await tx.appendEntry(runtime.conversationId, entry)).id }; return { status: "terminal", outcome: { status: "completed", result } };}completed nằm trong cùng một commit, nên crash không thể tách chúng. Kết quả trả về submissionId để host theo dõi lúc bản tóm tắt thực sự được đặt, hoặc settle stale (README.md:555–560).Vì sao hai đường đặt khác nhau? Compaction chặn chạy khi generation đang giữ run và chờ nó, nên không ai khác viết vào conversation; nối thẳng là an toàn. Compaction nền hay thủ công chạy song song với run, mà chỉ Harness mới được nối vào một conversation đang bận, nên bản tóm tắt phải là một write submission đặt ở boundary kế (compaction.ts:428–433). Khi nhiều compaction cùng chạy, quy tắc stale chọn kẻ thắng mà không cần phối hợp: compaction nền B chọn ở tail 100, cắt ở 70; trong lúc B tóm tắt, compaction chặn A cắt ở 150 và nối bản tóm tắt; bản tóm tắt của B tới sau, head 70 nằm trước 150, nên settle stale (spec.md:2600–2611).
Lần theo một SIGKILL giữa lời gọi tool#
Giờ ghép mọi mảnh lại. Kịch bản: host Node dùng SQLite, registry có CodingTools (trong đó có bash) và extension subagent của 22-subagent-foreground.ts, tool khai báo replay: "safe". Người dùng gửi input S1. Generation G1 trả lời bằng hai lời gọi trong cùng một vòng song song: subagent (lời gọi a) và bash với npm test (lời gọi b). Subagent tạo conversation con c2, gửi submission S2, và generation G2 của c2 đang stream câu trả lời. npm test đã in vài dòng. Đúng lúc đó tiến trình bị kill -9.
Ví dụ minh hoạ: những gì đã commit lúc tiến trình chết
c1 pi.live.run = { taskId: G1, inputs: [S1] } S1: placed
c1 pi.live.tools = [ { callId: a, taskId: Ta, running, details: { conversationId: c2 } },
{ callId: b, taskId: Tb, running, output: "> npm test\n 12 passing\n" } ]
G1 pi.generation waiting { phase: tools, on: [Ta, Tb], policy: allSettled }
Ta pi.tool running { phase: execute, arguments: { task: … }, replay: safe }
Tb pi.tool running { phase: execute, arguments: { command: "npm test" }, replay: unsafe }
c2 (owner Ta) pi.live.run = { taskId: G2, inputs: [S2] } S2: placed, requestId "subagent:Ta"
pi.live.generation = { attempt: 1, message: partial đã commit }
G2 pi.generation running { phase: request, attempt: 1, model, cutoff, … }
npm test) mất theo tiến trình. Mọi thứ ở trên đều đã nằm trong SQLite.- 1. Mở lạiTiến trình mới cài lại đúng các extension vào registry trước khi mở, để công việc phục hồi chạy được ngay (spec.md:732–734), rồi gọi
Harness.open(await openNodeSqliteStorage(path), …).openkiểm tra ba task dựng sẵn và gọischeduler.open()(harness.ts:540–561). Đối soát hạ Ta, Tb, G2 từrunningxuốngpendingvớirestarts: 1; G1 đangwaitingnên giữ nguyên (scheduler.ts:394–398). Không handler nào chạy;pi.livevẫn hiển thị hai slotrunningvới đầu ra cũ, và đó đúng là trạng thái một UI mới kết nối sẽ thấy. - 2. resume()Host gọi
harness.resume()(README.md:124); mộtsubmission.wait()cũng có tác dụng tương tự. Pass reservation đầu tiên: không task nào mangabandonOnRestart(Ta là replay-safe, G2 do Harness tạo khi nhận S2), nên nó reserve luôn. G1 bị bỏ qua vì#waitingOn()còn thấy Ta sống. Ta, Tb, G2 cùng version với definition hiện tại, nên một commit chuyển cả ba sangrunningvà tạo ba invocation (scheduler.ts:1488–1553). - 3. G2: generation đang streamPhase
requestchạy lại từ đầu. Commit đầu tiên của nó nối partial đã commit thành một entrypi.assistantvới stop reasonabortedtrong c2, có tính usage (generation.ts:430–439). Rồi nó gửi lại đúng các message tớicutoffđã ghim, với model, mức suy nghĩ và stream option đã ghim; entry aborted không vào request (generation.ts:209–236). Câu trả lờistopđi vàoanswer(): một commit nối câu trả lời, settle S2done, xoáruncủa c2 và kết thúc G2completed. Trạng thái mới xoárestarts. - 4. Ta: tool replay-safeỞ phase
execute, chính sách đã lưu và khai báo hiện tại đều làsafe, nên Ta xoá tiến độ cũ của slot và gọi lạiexecute()với đối số đã lưu, không hỏi lạibeforeTool(tool.ts:185–192). Tool subagent được viết để chạy lại vô hại:scanConversations({ ownerTaskId })tìm thấy c2 thay vì tạo con mới, và request IDsubagent:${api.taskId}làmsubmit()trả về chính S2 (22-subagent-foreground.ts:52–68; tra request ID ở submissions.ts:210–217).wait()trả về khi G2 settle S2. Ta nốipi.tool-resultcho lời gọi a và kết thúccompleted. Vì S2 được settle trong commit terminal của G2, lúc Ta settle thì c2 không còn việc sống, nên Ta đi thẳng tớiterminalthay vìcompleting. - 5. Tb: tool không replay-safePhase
executethấyreplay: "unsafe", không chạy lại, mà dựng kết quả lỗi từ slot: đầu ra đã commit, details cuối, các chẩn đoán đã ghi, cộng chẩn đoáninterrupted(tool.ts:194–197, tool.ts:824–840). Mô hình sẽ đọc phần đầu ra đã commit, rồi một khối<harness>chứa dòng[error] Tool bash was interrupted and may have partially run, đúng dạng mà test kiểm với một lệnhbashthật (harness-tools-recovery.test.ts:338–364). Tb kết thúcfailedkèmentryId. - 6. G1 tỉnh dậyCommit terminal của Ta và Tb đi qua
#track(), hàm đưa task đang chờ chúng (G1) về danh sách chạy được (scheduler.ts:1026). Pass reservation kế thấyonđã terminal hết, chuyển G1 từwaitingsangrunning, và phasetoolsgọifinishToolRound(): đọc kết quả, chạyafterTools, không cóterminatehayhandoff, nên áp boundarypostTools, tạo generation G3 và trao run cho nó. G1 kết thúccompleted; S1 vẫnplaced. - 7. G3 và submissionG3 chuẩn bị request mới. Context có câu trả lời gọi tool của G1 và hai kết quả theo thứ tự lời gọi: câu trả lời của subagent, và lỗi interrupted của
bashvới phần đầu ra một phần. Mô hình tự quyết định chạy lạinpm testhay không. Khi G3 trả lời,answer()settle S1done. Host lấy lại submission bằngharness.submission(id)vàwait()(README.md:138); cuối cùngpi.livecủa c1 trở về{}, như test khẳng định (harness-tools-recovery.test.ts:125–132).
Ba biến thể làm rõ vì sao từng chi tiết tồn tại:
- Tiến trình chết trước khi Tb ghi ý định. Checkpoint vẫn là
call, nên cả phase chạy lại: phân giải, kiểm tra,beforeToollần nữa (memo giữ quyết định cũ), ghi ý định, rồi chạy lệnh lần đầu tiên. Không có lỗi interrupted, vì chắc chắn lệnh chưa chạy. - Không cài lại extension subagent. Tên tool của Ta không phân giải được, nên bị coi là unsafe: Ta nhận
interruptedvà kết thúcfailed.failedlà ý định huỷ; nếu lúc đó c2 còn G2 sống (thường là vậy, vì G2 đang gửi lại request), Ta giữcompleting, pass đối soát đánh dấu abort G2, handler abort của G2 chuyển partial và settle S2unansweredvới lý doaborted; chỉ khi đó Ta mới terminal. Đây chính là lý do kết quả interrupted dùngfailed(tool.ts:195–196). - Cùng một crash lặp lại năm lần liên tiếp trước khi Ta, Tb, G2 kịp commit trạng thái mới: cả ba bị đỗ với lý do
restart_limit, conversation vẫn bận, và host phải gọiretryTask().
Task con và subagent qua ví dụ#
Checkout với bốn payment (ví dụ 24)
test/examples/24-child-tasks.ts là ví dụ chuẩn của cây sở hữu bên trong một conversation. Mỗi payment trừ tiền (hiệu ứng), ngủ 100 ms chờ ngân hàng xác nhận mà không giữ invocation, rồi hoàn tất; thẻ hết hạn bị từ chối ngay. Handler abort() của payment tự hoàn tiền, tức là mỗi task hoàn tác hiệu ứng của chính nó:
const Payment = defineTask<PaymentInput, PaymentState, PaymentResult>({ name: "example.payment", version: 1, initial: () => ({ phase: "charge" }), phases: { charge: async (task, runtime, taskContext) => { const card = task.input.card; if (card.startsWith("expired")) { const error = { message: `${card} declined` }; await runtime.commit(() => ({ status: "terminal", outcome: { status: "failed", error } }), taskContext); return; } charged.add(card); // The bank confirms after a moment: wait for it without holding an invocation. await runtime.sleepUntil(runtime.now() + 100, { phase: "confirm" }, taskContext); }, confirm: async (task, runtime, taskContext) => { const card = task.input.card; await runtime.commit( () => ({ status: "terminal", outcome: { status: "completed", result: { card } } }), taskContext, ); }, }, // Each payment undoes its own effect when aborted. abort: async (task, runtime, taskContext) => { const refunded = charged.delete(task.input.card); console.log(` payment ${task.input.card} aborted${refunded ? ", refunded" : ""}`); await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext); },});// The checkout creates its payments as child tasks and waits for all of them.// With `failFast`, the first payment that fails aborts the others; the checkout// itself is not aborted and decides its outcome once every payment is done.type CheckoutState = { phase: "pay" } | { phase: "decide"; payments: TaskId<PaymentResult>[] };const Checkout = defineTask<{ cards: string[] }, CheckoutState, string>({ name: "example.checkout", version: 1, initial: () => ({ phase: "pay" }), phases: { pay: async (task, runtime, taskContext) => { await runtime.commit(async (tx) => { const payments: TaskId<PaymentResult>[] = []; for (const card of task.input.cards) { payments.push(await tx.createTask(Payment, { card }, { ownership: { kind: "task", taskId: task.id } })); } return { status: "waiting", checkpoint: { phase: "decide", payments }, on: payments, policy: "failFast" }; }, taskContext); }, decide: async (task, runtime, taskContext) => { const outcomes = await runtime.outcomes(task.state.checkpoint.payments, taskContext); console.log(` payments: ${outcomes.map((outcome) => outcome.status).join(", ")}`); const paid = outcomes.every((outcome) => outcome.status === "completed"); await runtime.commit( () => paid ? { status: "terminal", outcome: { status: "completed", result: "order placed" } } : { status: "terminal", outcome: { status: "failed", error: { message: "payment failed" } } }, taskContext, ); }, }, // Runs only after every payment is done, so the refunds have already happened. abort: async (_task, runtime, taskContext) => { console.log(" checkout aborted"); await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext); },});waiting nằm trong cùng một commit: không có thời điểm nào checkout đã tạo payment mà chưa ghi là đang chờ chúng.Ví dụ chạy ba kịch bản. Một thẻ bị từ chối: payment của expired-2 kết thúc failed; pass đối soát thấy checkout đang chờ failFast và đánh dấu abort ba payment còn sống; chúng đang ngủ, mà abort mark kết thúc giấc ngủ, nên handler abort của từng cái chạy và hoàn tiền; khi cả bốn terminal, checkout tỉnh dậy ở decide và đọc [aborted, failed, aborted, aborted] (spec.md:2396–2402). Khách huỷ: abortTask(checkout) đánh dấu checkout, cascade đánh dấu các payment, handler abort của payment chạy trước, rồi mới tới Checkout.abort, nên dòng "checkout aborted" luôn in sau các dòng hoàn tiền. Tiến trình dừng giữa chừng: Harness đóng khi các payment đang ngủ; Harness mới chỉ cần waitForTask(id), lời gọi này bật lập lịch, và các deadline đã lưu đưa payment tới confirm (24-child-tasks.ts:137–143). Trong lúc chạy, ví dụ in cây task từ task graph (24-child-tasks.ts:106–116).
Subagent tiền cảnh (ví dụ 22)
Subagent là một conversation con do task của lời gọi tool sở hữu. Tool tạo nó bên trong api.commit() với ownership: { kind: "task", taskId: api.taskId }, rồi điều khiển qua api.conversation(id):
defineTool({ name: "subagent", description: "Delegate a self-contained task to a subagent and get its answer back.", parameters: Type.Object({ task: Type.String({ description: "What the subagent should do" }) }), // Safe to rerun after a crash: a rerun finds the child it already created and the submission it already made. replay: "safe", execute: async (args, api, callContext) => { const { task } = args; // The child is owned by this tool call's task, so aborting the call aborts the child, and the call // finishes only once the child's work is done. const child = await api.commit(async (tx) => { // Ownership records the child: a rerun of this call finds it instead of creating another. const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0]; if (existing !== undefined) return existing.id; // Starts as a copy of this conversation's agent: model, thinking level, cwd, extensions, tools. const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); // Without this extension, the child is not offered this tool. await configure(tx, created.id, { extensions: { remove: [Subagent] } }); return created.id; }, callContext); // A UI watching the parent sees this and can attach to the child. await api.details({ conversationId: child }, callContext); const handle = (await api.conversation(child, callContext))!; // The request ID makes a rerun get back the submission it made before the crash. const request = { type: "input", content: task, requestId: `subagent:${api.taskId}` } as const; const settled = await (await handle.submit(request, callContext)).wait(callContext); if (settled.status !== "done" || settled.type !== "input") { throw new Error(`Subagent failed: ${settled.status}`); } const text = await answerText(api, settled.answer, callContext); return { output: [{ type: "text", text }], details: { conversationId: child } }; },}),api.taskId, không dùng ID lời gọi của provider: ID đó không duy nhất trong một Session (spec.md:3500–3503).Mỗi dòng có chủ đích phục hồi. scanConversations({ ownerTaskId }) dùng index sở hữu để lần chạy lại tìm lại con thay vì tạo con thứ hai; request ID ổn định làm submit() lần hai trả về submission cũ; configure(... { extensions: { remove: [Subagent] } }) chặn subagent tự gọi subagent. Quan hệ sở hữu cho hành vi còn lại miễn phí: huỷ lời gọi thì huỷ con, execute() ném lỗi hay bị interrupted (kết thúc failed) cũng huỷ con, và conversation cha chỉ idle khi con đã idle (README.md:672–676). UI tìm con qua details của lời gọi và in sự kiện của con thụt vào dưới lời gọi (22-subagent-foreground.ts:113–125).
Subagent nền bền vững (ví dụ 23)
Subagent nền phải sống lâu hơn các lượt của agent chính: Esc của người dùng ở conversation chính và lệnh chờ idle không được chạm tới nó. Ví dụ 23 dùng hai task background do conversation chính sở hữu. Anchor sở hữu conversation con và chỉ có một phase commit completed ngay lần chạy đầu; chừng nào con còn việc sở hữu thường, kết quả đó được giữ ở completing, rồi anchor ở yên trạng thái terminal. Vì là background, nó là ranh giới mà phép duyệt thường dừng lại, nhưng abort({ background: true }) vẫn đi xuyên qua được, và một owner đã terminal thì không bao giờ lan abort xuống (23-subagent-background.ts:57–72). Mỗi tin nhắn gửi subagent do một reporter chuyển:
const result = await api.commit(async (tx) => { const state = await tx.doc(Subagents, api.conversationId); // Both tasks belong to the main conversation and are background: its Esc and idle waits skip them. const background = { ownership: { kind: "conversation" }, background: true } as const; if (action === "spawn") { if (Object.hasOwn(state.agents, name)) return `${name} already exists; use send.`; const anchor = await tx.createTask(Anchor, null, background); // Owned by a task of the main conversation: starts as a copy of the main agent. const child = await tx.createConversation({ ownership: { kind: "task", taskId: anchor } }); // Subagents cannot start subagents, and know who they are. await configure(tx, child.id, { extensions: { remove: [SubagentTools] }, instructions: `You are the subagent "${name}". Answer the main agent's requests.`, }); state.agents[name] = { conversationId: child.id, reported: [] }; } const conversationId = state.agents[name]!.conversationId; const input = { name, conversationId, message, followUp: action === "send" && followUp === true }; state.reporters[api.taskId] = await tx.createTask(Reporter, input, background); return action === "send" ? `Sent to ${name}.` : `Started ${name}.`;}, callContext);deliver: async (reporter, runtime, taskContext) => { const { name, conversationId, message, followUp } = reporter.input; // While the subagent is busy, a steer reaches it at its next step and a follow-up after its current answer. const subagent = (await runtime.conversation(conversationId, taskContext))!; const request = { type: "input", content: message, whenBusy: followUp ? "followUp" : "steer" } as const; const submission = await subagent.submit({ ...request, requestId: `subagent:${reporter.id}` }, taskContext); const settled = await submission.wait(taskContext); // One commit decides the report and records the answer as delivered, so a restart does not decide again. await runtime.commit(async (tx) => { const next = (report?: string) => ({ status: "running", checkpoint: { phase: "report", report } }) as const; // `aborted`: stopped, or withdrawn while queued. Nothing to report. if (settled.status === "unanswered") { return next(settled.reason === "aborted" ? undefined : `[subagent ${name} failed: ${settled.reason}]`); } // Always an answered input here; the check tells TypeScript. if (settled.type !== "input") return next(); const agent = (await tx.doc(Subagents, runtime.conversationId)).agents[name]!; if (agent.reported.includes(settled.answer)) return next(); agent.reported.push(settled.answer); const answer = (await tx.entry(AssistantEntry, settled.answer))?.model?.[0] as AssistantMessage; return next(`[subagent ${name} answered, no reply needed] ${textOf(answer)}`); }, taskContext);},report: async (reporter, runtime, taskContext) => { const report = reporter.state.checkpoint.report; if (report !== undefined) { const main = (await runtime.conversation(runtime.conversationId, taskContext))!; const input = { type: "input", content: report, whenBusy: "followUp" } as const; await main.submit({ ...input, requestId: `subagent-report:${reporter.id}` }, taskContext); } await runtime.commit( () => ({ status: "terminal", outcome: { status: "completed", result: null } }), taskContext, );},Một chi tiết ngược với ví dụ 22: tool quản lý subagent nền khai báo replay: "unsafe", vì chạy lại lệnh stop sau crash có thể dừng nhầm công việc mới hơn (23-subagent-background.ts:145–147). Mô hình thấy lời gọi bị gián đoạn và có thể hỏi status. Replay-safe hay không là quyết định nghiệp vụ, không phải mặc định nên bật.
Code mode bền#
Code mode cho mô hình viết JavaScript gọi các tool của conversation như await tools.read({ path }), chạy trong sandbox QuickJS của pi-codemode; cách codemode hoạt động trong coding agent nằm ở chương Codemode, MCP và tool search. Ở Pi Durable, vấn đề khó hơn: một script là trạng thái VM trong bộ nhớ, và chú thích đầu file nói thẳng vấn đề lẫn lời giải:
/** * Durable code mode: a tool that runs model-written JavaScript in a QuickJS sandbox, whose script survives a restart. * * Problem: a script is in-memory VM state. A restart while * * const results = await Promise.all(paths.map((path) => tools.read({ path }))); * * waits for the last of 200 reads would lose the script and every read it made. * * Solution: deterministic replay (`CodemodeSandboxBase.start()` in pi-codemode). The VM only runs when the host hands * it a result; given the same start data, clock, seed, and results in the same order, it makes the same calls with * the same arguments. The tool is replay-safe: after a restart, `execute()` runs again, starts the script again from * the stored start data, and hands it the results it got before, in the order it got them, from a journal written * before each delivery. Nested tool calls reattach to their tasks by key (`c<VM call id>`), so they never run twice * unless their own replay policy reruns them; their results come from `NestedCallDoc`. Then the script continues live. * * Documents, scoped to the code mode call's task and gone when it settles: * - `pi.codemode.run`: the start data, written once before the VM starts, and the outcome, written once when the call * decides it (with the store writes, in one transaction), so a rerun after that returns it without a VM. * - `pi.codemode.journal`: one entry per delivered result, in delivery order: the VM call ID, the clock value, the * calls the VM had issued after processing it (once acknowledged), and a global's outcome, which nothing else stores. * * Each result's acknowledged call count is recorded with the next journal commit, or at once when the VM has processed * everything delivered: a script waiting on a long call is where a restart usually finds it. A sequential script * therefore costs one journal commit and one count commit per result, on top of its nested calls' own; that is the * price of checking the most common crash point. * * A call is launched only once the count of calls issued in the drain that made it is committed. So every key an earlier * attempt admitted is at most its last recorded count; a replay checks every recorded count, so when it releases its * calls it has made every call an earlier attempt could have admitted, and later calls are new. * * A rerun refuses to replay (no new nested calls; nested calls admitted earlier may still resume on their own and are * aborted by the call's cleanup) when this module's version or the VM's fingerprint changed, when the deadline passed, * or when the script diverges: it makes another call, or calls in another order, than the journal says. Each ends the * call with an error result. */Lời giải dựa trên một tiền đề: VM chỉ chạy khi host trao cho nó một kết quả, và mọi nguồn bất định khác đã bị khoá ở chế độ tất định của pi-codemode:
* Deterministic mode (`data.deterministic`, used by `CodemodeSandboxBase.start()`): a script must make the same* calls with the same arguments every time it runs with the same results, so a host can replay it after a restart.* The VM is otherwise deterministic, because it only runs when the host delivers something and has no timers.* What remains is fixed here:* - Time: every WASI clock reads virtual time, `startedAt` and then the `now` of the result being processed, never going backwards.* While the context is created, the realtime clock reads a value derived from the seed instead, because QuickJS* seeds `Math.random()` from the first clock read, and which read comes first is the engine's business.* - Randomness: `random_get` draws from a PRNG over the seed.* - Timezone: UTC, not the host's.* - Garbage collection: `WeakRef` and `FinalizationRegistry` are not available; collection timing is not.* - Delivery order: results carry a `seq` and are applied strictly in that order, buffering early ones, because a* transport (RPC replies) may deliver them in another order than the host sent them.* The VM reports `started` with its fingerprint before the script runs, and `idle` with the number of calls issued* after each drain, so the host can check a replay step by step.*/Tool codemode là callers: ["model"] và replay: "safe" (codemode.ts:236–248). Mỗi tools.x() trong script là một lời gọi lồng nhau với khoá c<id>, trong đó id là số thứ tự VM đặt cho lời gọi. Ba document lưu những gì cần cho phát lại: pi.codemode.run (dữ liệu khởi động, ghi một lần trước khi VM chạy; và kết cục, ghi một lần), pi.codemode.journal (mỗi kết quả đã giao một mục, theo thứ tự giao), cả hai có phạm vi task; và pi.codemode.store cho store()/load(), phạm vi conversation, fork theo asOf (codemode.ts:98–171).
const found = await api.commit(async (tx): Promise<Found> => { const run = await tx.doc(CodemodeRunDoc, api.taskId); if (run.result !== undefined) return { done: copyJson(run.result as JsonValue) as ToolExecutionResult }; const rerun = run.start !== undefined; if (run.start === undefined) { const store = await tx.doc(CodemodeStoreDoc, api.conversationId); const timeout = sourceOptions.timeoutMs; run.start = { version: CODEMODE_MODULE_VERSION, startedAt: now, seed, ...(timeout === undefined ? {} : { deadline: now + timeout }), tools, namespaces, globals, maxOutputTokens: sourceOptions.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS, store: copyJson(store.values as JsonValue) as Record<string, JsonValue>, }; } const journal = await tx.doc(CodemodeJournalDoc, api.taskId); return { start: copyJson(run.start as unknown as JsonValue) as unknown as Start, journal: copyJson(journal.entries as unknown as JsonValue) as unknown as JournalEntry[], firstIssued: journal.firstIssued, rerun, };}, context);if ("done" in found) return found.done;if (this.start.version !== CODEMODE_MODULE_VERSION) { return this.decide( errorResult( `The script cannot resume: it started under code mode version ${this.start.version}, this is ${CODEMODE_MODULE_VERSION}.`, ), );}const remaining = this.start.deadline === undefined ? undefined : this.start.deadline - this.api.now();if (remaining !== undefined && remaining <= 0) { return this.decide(errorResult(`Script timed out: its deadline passed while it was interrupted.`));}seed và startedAt được tính ở mỗi lần gọi execute() nhưng chỉ lần đầu được ghi; lần chạy lại dùng giá trị đã lưu, nên Date.now() và Math.random() trong script cho cùng kết quả.Lúc chạy thật, một lời gọi xong được nối vào journal rồi mới giao cho VM, mỗi lúc chỉ một commit journal đang bay, và số lời gọi VM đã phát sau khi xử lý từng kết quả được ghi lại (codemode.ts:962–1006). Một lời gọi chỉ được khởi chạy khi con số đó đã commit, nên mọi khoá mà lần thử trước có thể đã nhận đều không vượt quá con số đã ghi cuối cùng. Sau restart, execute() chạy lại, khởi động VM từ start, giao lại các kết quả trong journal theo đúng thứ tự, mỗi kết quả khi VM đã phát lời gọi tương ứng; kết quả của tool lấy từ lời gọi lồng nhau theo khoá, của global lấy từ journal. Rồi script chạy tiếp như thật. Test: script echo a; hold; echo b bị cắt ở hold, chạy lại thì echo mỗi khoá chỉ chạy một lần, VM khởi động hai lần (codemode.test.ts:212–227); đồng hồ và số ngẫu nhiên giữ nguyên qua restart (codemode.test.ts:259–272).
Lần chạy lại kết thúc lời gọi bằng một kết quả lỗi đã lưu, thay vì phát lại, trong các trường hợp (spec.md:3916–3941):
- Version của module code mode đã đổi (
CODEMODE_MODULE_VERSION, hiện là 2, codemode.ts:82–86) hoặc deadlinetimeout_msđã qua trong lúc bị gián đoạn: kiểm tra trước khi khởi động VM (dòng 449–459 ở trên). - Fingerprint của sandbox đã đổi: VM báo fingerprint trước khi script chạy; khác với cái đã ghi thì lời gọi kết thúc trước khi giao hay gắn lại bất cứ thứ gì (codemode.ts:615–642).
- Script đi đường khác: số lời gọi sau một kết quả khác con số đã ghi, VM chờ mà không phát lời gọi mà journal có, một khoá hay global bị gọi với tên hoặc đối số khác, hay script kết thúc ở chỗ lần trước còn chạy tiếp (codemode.ts:699–735, codemode.ts:492–510). Khi đó kết quả lỗi được lưu, rồi VM bị dừng bằng một lỗi mà script không bắt được.
Một trường hợp ngược lại không phải kết cục: host đóng sandbox trong lúc script chạy (ví dụ khi tắt máy) thì lời gọi không lưu gì và kết thúc cùng invocation, để lần sau phát lại script (codemode.ts:481–491). Các lời gọi lồng nhau đang chạy lúc restart tự theo chính sách replay của chúng; bước dọn dẹp của tool code mode abort những cái lần chạy lại không dùng tới.
ExecutionEnv và các tool dựng sẵn#
Tool chạm vào file và tiến trình chỉ qua api.env, không bao giờ qua một môi trường bắt được lúc dựng tool. Task tool dựng môi trường cho từng lời gọi bằng HarnessOptions.env với ID và cwd của conversation, nên một thư mục hay một sandbox riêng cho mỗi conversation không cần wrapper nào (spec.md:3283–3290). Không có môi trường thì tool dựng sẵn ném lỗi, và lỗi đó thành một kết quả lỗi bình thường (tools/env.ts:4–8).
/** Portable filesystem capability. Operations return failures rather than throwing. */export interface FileSystem { /** * The file namespace: equal ids see the same files at the same paths, whatever their `cwd`. Every local Node * environment shares one id; each container or remote host has its own. */ readonly id: string; cwd: string; absolutePath(path: string, context: Context): Promise<Result<string, FileError>>;export interface Shell { /** * Run a command. A string runs through the environment's shell. An array runs `command[0]` directly with the rest * as its arguments, without a shell, so they reach the program unparsed. Aborting the context or a timeout kills * only this command's processes. */ exec( command: string | readonly string[], options: ShellExecOptions | undefined, context: Context, ): Promise<Result<ShellExecResult, ExecutionError>>; /** Kill every command this environment still runs; for its owner's shutdown, never for one request. */ cleanup(context: Context): Promise<void>;}export interface ExecutionEnv extends FileSystem, Shell {}Result thay vì ném lỗi cho các thất bại dự kiến (index.ts:3–17). Huỷ context chỉ dừng lời gọi đó; cleanup() là việc tắt máy của chủ sở hữu, không bao giờ của một request.Những thành viên quan trọng ngoài phần đọc ghi cơ bản:
id: hai môi trường cùngidthấy cùng file ở cùng đường dẫn. MọiNodeExecutionEnvcục bộ có chung"node:local"(node.ts:612–626); mỗi container hay máy từ xa cóidriêng. Hàng đợi ghi file dựa vào nó (bên dưới).exec(command, options, context): chuỗi thì chạy qua shell của môi trường, mảng argv thì chạy thẳng chương trình không qua shell, nên host dựng lệnh từ dữ liệu (tên file) không phải quote. Mỗi đoạnonOutputghi nó đến từstdouthaystderr;spillghi toàn bộ đầu ra ra file khi vượt ngưỡng;windowcho phép môi trường ở xa bỏ qua đầu ra nằm ngoài phần đuôi mà người gọi sẽ giữ (index.ts:300–331).openBinaryReader(): đọc theo vị trí trên một file đã mở, kể cả khi đường dẫn bị đổi tên;scanLinesđếm và định vị dòng trong một lượt bên trong môi trường, nên toolreadkhông phải chuyển cả file (index.ts:95–108).openDirReader()đọc thư mục theo trang (index.ts:159–168).listFiles(), tuỳ chọn: liệt kê cả cây nhưfd, bỏ những gì git ignore, có giới hạn.NodeExecutionEnvkhông cài đặt nó;RemoteExecutionEnvvàLocalExecutionEnvcủa pi-env thì có, và người gọi tự duyệt khi thiếu hoặc gặpnot_supported(README.md:456–460). Môi trường từ xa thuộc chương pi-env.
Môi trường là thứ được tin cậy, không phải ranh giới cô lập: kiểm tra đường dẫn chuẩn trước khi mở không ngăn được một lần đổi tên hay tráo symlink đồng thời (spec.md:3416–3419). Bộ test tuân thủ registerEnvConformance() cho phép kiểm một môi trường tự viết, giống như với storage (README.md:454).
Các tool dựng sẵn
@earendil-works/pi-durable/tools cung cấp read, write, edit, bash (cùng powershell), port từ tool của coding agent (chương Công cụ), và extension CodingTools gồm bốn tool đầu; không có gì tự cài chúng (tools/index.ts:27–39). Không tool nào trong số này khai báo replay, nên tất cả đều là unsafe, kể cả read: một read bị gián đoạn cũng trả kết quả interrupted thay vì đọc lại.
bashgiữ phần đuôi đầu ra (retain: "tail"), đẩy từng đoạn vàoapi.output(), spill toàn bộ ra file khi vượt 50 KiB hoặc 2 000 dòng và báo đường dẫn qua chẩn đoánfull_output, trảstructuredOutput: { output, truncated, fullOutputPath?, exitCode }, biến exit khác 0 thành kết quả lỗi có chẩn đoánexit_codenhưng vẫn mang đầu ra, và ném lỗi khi timeout (bash.ts:87–158).readđọc quaopenBinaryReader, phát hiện ảnh theo nội dung, và đọc lại một lần nếu file đổi trong lúc đọc (read.ts:89–131). Không có bộ xử lý ảnh, PNG, JPEG, GIF và WebP nằm trong giới hạn của mô hình được gửi nguyên, các ảnh khác bị từ chối; với bộ xử lý Photon (WebAssembly, chạy trong worker thread trên Node), ảnh được xoay theo EXIF, đổi định dạng và thu nhỏ cho vừa (README.md:249–258).writevàedittuần tự hoá thao tác đọc-sửa-ghi trên cùng một file:
export async function withFileMutationQueue<T>( env: ExecutionEnv, path: string, fn: () => Promise<T>, context: Context,): Promise<T> { const key = await mutationKey(env, path, context); // Take the slot without awaiting, so no other call can take it in between. const previous = queues.get(key) ?? Promise.resolve(); let release = (): void => {}; const done = new Promise<void>((resolve) => { release = resolve; }); const tail = previous.then(() => done); queues.set(key, tail); await previous; try { return await fn(); } finally { release(); if (queues.get(key) === tail) queues.delete(key); }}env.id cộng đường dẫn chuẩn (file-mutation-queue.ts:7–10), nên hai lời gọi với hai đối tượng môi trường mới cho cùng một file vẫn xếp hàng, còn file khác hay file system khác không bao giờ phải chờ. Đây không phải khoá chống lại bash hay tiến trình khác.Task graph và telemetry#
harness.taskGraph(context) trả về mọi task đang sống của Session dưới dạng một Chord state (xem chương Chord), dành cho bảng task trong UI hay để gỡ lỗi; watchTaskGraph() trả cùng giá trị qua một watch. Mỗi nút giữ bản ghi đã bỏ input, payload checkpoint, payload kết quả và memo:
/** A live task's durable status without its checkpoint and outcome payloads (spec §9.5). */export type TaskGraphState = | { readonly status: "pending" | "running"; readonly phase: string } | { readonly status: "waiting"; readonly phase: string; readonly on: readonly TaskId[]; readonly policy: JoinPolicy; } /** Asleep until the Harness clock reaches `until`. */ | { readonly status: "sleeping"; readonly phase: string; readonly until: number } /** Outcome held until its ordinary owned work drains. */ | { readonly status: "completing"; readonly outcome: TaskOutcome<JsonValue>["status"] };export type TaskGraphNode = { readonly id: TaskId; readonly kind: string; readonly conversationId: ConversationId; /** Owner task; absent for a conversation-owned task. */ readonly owner?: TaskId; readonly background: boolean; readonly abortRequested: boolean; readonly state: TaskGraphState; /** * Why it cannot run here, when it cannot: no registered definition takes it (`missing_task`, `task_too_old`, * `migration_failed`), or the restart limit parked it (`restart_limit`). Follows registry changes. */ readonly blocked?: BlockedReason; /** Conversations this task owns, in ID order. */ readonly conversations: readonly ConversationId[];};running là trạng thái bền: sau khi mở lại, task từng chạy hiện là pending cho tới khi được reserve. blocked được suy ra và đi theo thay đổi registry, kể cả khi không có commit nào (task-graph.ts:159).Graph được dựng một lần trên dòng commit ở lần quan sát đầu, rồi tiến theo publication của từng commit: một nút xuất hiện cùng commit tạo task và biến mất cùng commit làm task terminal (task-graph.ts:203–251). Nó chỉ chứa task sống: khi task anchor của một subagent đã terminal, task sau trong conversation con là nút cấp cao nhất, và UI muốn đặt nó dưới cha phải đọc ConversationRecord.owner (spec.md:5052–5056).
Về telemetry, mỗi phase invocation là một span pi.durable.phase bao cả commit quyết định của nó, nhưng không bao phase kế, một lần chờ bền hay một restart. Thuộc tính gồm ID và tên task, phase, loại (run/abort), attempt, resumed (lần chạy đầu của một task nạp trong tiến trình này) và outcome (next=<phase>, waiting, sleeping, done, faulted, aborted); sự kiện pi.durable.checkpoint và pi.durable.replay đánh dấu đổi phase và lời gọi lồng nhau được trả từ kết quả đã lưu (telemetry.ts:77–115; span được mở trong scheduler.ts:1739–1803). Schema không chứa nội dung: chỉ ID và siêu dữ liệu điều khiển, không có message, đối số hay kết quả (README.md:82). pi-telemetry và cách host nối nó vào thuộc chương pi-env, telemetry và evals.
Quyết định thiết kế#
- Vòng lặp agent thành dữ liệu, scheduler thì chung
- Thay vì một vòng lặp bền đặc biệt cho mô hình và tool, Pi Durable cắt vòng lặp thành các task dựng sẵn và chạy chúng bằng một scheduler không biết gì về run hay loại task (spec.md:2273). Cùng một bộ máy phục hồi, abort, migration, đỗ task và task graph phục vụ generation, tool, compaction lẫn task của extension. Cái giá là số commit: mỗi lượt có vài commit cho checkpoint, cộng commit tiến độ tối đa 100 ms một lần trong lúc stream.
settings.progresstồn tại vì giá đó đáng kể với storage ở xa (types.ts:524–533). - Không có đường hiển thị nào chưa bền
- Bất biến 2 và 3 của spec: document chỉ được công bố sau khi storage commit thành công, và mọi tiến độ hiển thị đều bền (spec.md:63–68). Hệ quả là UI đọc
pi.livechứ không nghe một kênh sự kiện riêng, và kết quảinterruptedsau crash mang đúng phần đầu ra mà UI đã thấy, cộng một chẩn đoán nói lời gọi bị gián đoạn. Spec liệt kê "visible-undurable publication" trong các non-goal (spec.md:5604–5610). - Mặc định unsafe, và cả hai bên phải đồng ý mới replay
- Harness không thể biết một hiệu ứng bên ngoài đã xảy ra hay chưa. Chạy lại mặc định sẽ nhân đôi
deployhay thanh toán; im lặng bỏ qua sẽ giấu sự thật khỏi mô hình. Pi Durable chọn nói thật: mô hình nhận kết quả "đã bị gián đoạn và có thể đã chạy một phần" kèm phần đầu ra đã commit, và tự quyết định bước tiếp (README.md:277). Replay là cam kết của tác giả tool, nên khai báo hiện tại được quyền phủ quyết. - Chờ là trạng thái, không phải await
- Bản 1.4.0 xoá sleep trong phase vì nó giữ invocation và làm deadline trôi sau mỗi restart (CHANGELOG.md:7); retry backoff, poll phản hồi deferred và backoff của compaction cũng chuyển sang sleep (CHANGELOG.md:33). Cùng với
onWake, một Durable Object có thể ngủ đông trong lúc chờ backoff 10 phút và chỉ thức dậy khi alarm reo. - Đồng thời có cấu trúc thay cho phụ thuộc tự do
- Không có "task A phụ thuộc task B" tách rời; thứ tự đến từ việc một task chờ task khác, và mọi task đều có owner (spec.md:2091–2093). Đổi lại, ba điều tự đúng: không công việc nào rò rỉ sau khi owner kết thúc (
completing), abort luôn tới được việc con, và việc bù trừ chạy từ dưới lên ở đúng tầng đã gây ra hiệu ứng. - Thiếu code thì chặn, không kết thúc
- Một extension chưa được cài lại sau restart là tình huống phục hồi được, nên task chờ thay vì bị huỷ; chỉ một yêu cầu huỷ thật mới biến nó thành
orphaned, và tên kết quả nói rõ không có code dọn dẹp nào đã chạy (spec.md:2218–2242). Điều này là điều kiện để tải lại extension ngay trong tiến trình mà không phải đóng Harness. - Phát lại tất định thay vì chụp VM
- Code mode lưu dữ liệu vào và thứ tự kết quả, không lưu bộ nhớ của QuickJS. Journal nhỏ, đọc được, kiểm tra được từng bước (đếm lời gọi sau mỗi kết quả), và một sandbox khác (fingerprint khác) hay một module khác (version khác) bị từ chối thay vì phát lại sai.
- Chỉ mục sở hữu giới hạn theo công việc đang sống
- Scheduler không duyệt mọi task ở mỗi pass; cascade bắt đầu từ task có ý định huỷ, việc con và phạm vi idle là các phép đi xuống trên một chỉ mục trong bộ nhớ chỉ giữ chuỗi phía trên task còn sống. CHANGELOG 1.2.0 ghi lại hiệu quả: chuỗi 500 task sở hữu nhau từ 12 s xuống 50 ms, 8 000 lời gọi lồng nhau song song từ 18,8 s xuống 2,7 s (CHANGELOG.md:95–96).
Cạm bẫy và trường hợp biên#
- Việc sau một
awaittrong phase không bền. Phase chạy lại từ đầu; mọi thứ cần sau một lần chờ phải nằm trong checkpoint của phase kế (README.md:162). Giá trị ngẫu nhiên, thời điểm, hay giá trị đọc từ document mà lần chạy lại phải thấy giống hệt thì ghi vào memo, checkpoint hoặc input trước khi dùng (spec.md:3458–3462). - Phase quên commit thì bị
faulted, không phải "chạy lại cho tới khi được" (quy tắc 6). - Giao dịch dài. Callback của commit giữ dòng commit; đừng
awaitmô hình, mạng, tiến trình hay một commit lồng trong đó. Đọc quasnapshot(),getTask()bên trong callback có thể treo vì nó xếp hàng sau chính callback đang giữ dòng; hãy đọc quatx(spec.md:5521–5528). - Tool dựng sẵn đều unsafe, kể cả
read. Đừng khai báoreplay: "safe"cho tool có hiệu ứng ngoài mà không có khoá idempotent; lời gọi lồng nhau của tool safe vẫn theo chính sách replay của riêng chúng. - Chạy lại thấy môi trường hiện tại:
cwdcủa conversation có thể đã đổi giữa lần chạy đầu và lần chạy lại (spec.md:5582–5584). - Việc con giữ owner. Công việc tiền cảnh mà một extension khởi động trong conversation của subagent giữ lời gọi tool, và qua đó giữ cả run; hỏi tiếp subagent trong lúc tool đang
completingkéo dài việc giữ. Việc không được giữ owner phải tạo do conversation sở hữu vàbackground(spec.md:5502–5512). - Handler
abort()không tạo được task con và không chờ, không ngủ được. Bù trừ ngay trong handler, hoặc qua task background do conversation sở hữu và chờ bằngwaitForTask()(spec.md:5513–5518). failFastchỉ áp cho task mình sở hữu, và lời chờ khép kín chu trình (chờ chính mình, chờ owner đang giữ mình) bị từ chối ngay khi commit.- Task bị đỗ vẫn giữ conversation bận. Một crash giết mọi task đang chạy, không chỉ thủ phạm; host nên hiển thị task bị đỗ và nút
retryTask(). - Mọi tiến trình mở cùng storage phải dùng cùng đồng hồ, vì
untilđược lưu theoHarnessOptions.now. Và chỉ một tiến trình được sở hữu một storage tại một thời điểm; không có khoá giữa các tiến trình (README.md:790). - Code không tôn trọng tín hiệu huỷ làm
close()treo, vì close join mọi invocation. Huỷ mộtContextchỉ huỷ lời gọi hay lần chờ đó, không bao giờ huỷ công việc chung (spec.md:2117–2120). - Tiền của subagent bị tính hai lần nếu tool trả
usagecủa conversation con trong kết quả của nó:pi.usagecủa con đã tính rồi (spec.md:5499–5501). Compaction cũng tốn một request tóm tắt mỗi lần thử, kể cả khi bản tóm tắt kết thúcstale. - Tên bắt đầu bằng
pi.cho task, document và entry thuộc về phần dựng sẵn theo quy ước; không có gì cưỡng chế (spec.md:5565–5568).
Đọc code ở đâu#
| Thứ tự | File | Đọc để hiểu |
|---|---|---|
| 1 | README.md | Concepts, Persist and Resume (sleep, crash loop, blocked), Tools, Code Mode, Abort and Subagents, Child Tasks. Đọc trước để có từ vựng. |
| 2 | spec.md §5 | Mô hình task chuẩn tắc: định nghĩa, effect sandwich, abort và sở hữu, đồng thời có cấu trúc, sleep và wake. Sau đó §7.3 (tool), §7.6 (code mode), §8 (task dựng sẵn). |
| 3 | types.ts | TaskRuntime, TaskDefinition, TaskOptions (208–391); TaskOutcome, TaskState, TaskRecord (554–704). |
| 4 | packages/durable/test/examples/13-recovery.ts, packages/durable/test/examples/24-child-tasks.ts | Hai task viết tay ngắn: memo, sleep, phục hồi; task con, failFast, abort từ dưới lên. 12-tasks.ts là ví dụ ý định, hiệu ứng, kết quả. |
| 5 | packages/durable/src/harness/scheduler.ts | Theo thứ tự: chú thích lớp (231–252), open (374), #observe (624), #reconcile (776), #reserve (1466), #run/#decide/#step (1711–1921), #runtime (2085). Bỏ qua lúc đầu phần chỉ mục sở hữu (1000–1290). |
| 6 | packages/durable/src/harness/live.ts | pi.live, endRun() và việc dọn dẹp khi scheduler tự ghi faulted/orphaned. |
| 7 | packages/durable/src/harness/generation.ts | Năm phase, streamResponse(), classify(), answer(), vòng tool. |
| 8 | packages/durable/src/harness/tool.ts | Phase call/execute, run(), finalResult(), settle(), lời gọi lồng nhau. |
| 9 | packages/durable/src/harness/inbox.ts, submissions.ts:201 | Boundary postTools/final, write chỉ để hiển thị, stale. |
| 10 | packages/durable/src/harness/compaction.ts, packages/durable/src/harness/context.ts | Ba cách khởi động compaction, chọn điểm cắt, đặt bản tóm tắt; cách context loại message lỗi và tự bù kết quả tool bị thiếu. |
| 11 | packages/durable/src/codemode/codemode.ts, vm.ts:23–37 | Journal, phát lại, phân kỳ; chế độ tất định của VM. |
| 12 | packages/durable/src/env/index.ts, packages/durable/src/env/node.ts, src/tools/ | Giao diện môi trường, bản cài Node, bốn tool dựng sẵn và hàng đợi ghi file. |
| 13 | packages/durable/test/harness-tasks-recovery.test.ts, packages/durable/test/harness-tools-recovery.test.ts, packages/durable/test/harness-generation-recovery.test.ts, packages/durable/test/harness-nested-tools-restart.test.ts, packages/durable/test/harness-blocked.test.ts | Bằng chứng chạy được cho mọi hành vi phục hồi trong chương: mỗi tên test là một câu khẳng định. |
| 14 | packages/durable/src/harness/task-graph.ts, packages/durable/src/harness/telemetry.ts | Graph task sống và schema telemetry không chứa nội dung. |
Tóm tắt#
- Mọi thứ harness chạy là một task: một máy trạng thái bền mà mỗi bước là một checkpoint đã commit. Phase chạy lại từ đầu sau crash, nên việc có hậu quả đi theo khuôn ý định, hiệu ứng, kết quả.
TaskStatecó sáu trạng thái. Code của task chỉ đổi trạng thái của chính nó quaruntime.commit()(checkpoint mới, chờ, ngủ, kết quả); reservation, đối soát, handover, fault, orphan thuộc về scheduler; mọi chuyển trạng thái đều được quyết định trên dòng commit.- Chờ (
waiting) và ngủ (sleeping) là trạng thái đã lưu, không phảiawait;wake()/onWakecho host biết khi nào cần một tiến trình. - Cây sở hữu làm cho abort chảy xuống, chạy từ dưới lên, và không việc con nào sống lâu hơn lúc owner kết thúc; background là ranh giới.
pi.generationghim request, commit partial tối đa 100 ms một lần, biến retry và poll thành sleep, compaction rồi thử lại một lần khi tràn context, và trao run cho generation kế sau mỗi vòng tool.pi.toolghi đối số cuối và chính sách replay trước khi chạy; sau crash nó chỉ chạy lại khi cả hai bên nói safe, còn lại trả cho mô hình một kết quảinterruptedvới phần đầu ra đã commit. Lời gọi lồng nhau gắn lại theo khoá; code mode phát lại script tất định từ journal.- Restart được đếm; task bị crash cắt ngang năm lần liên tiếp bị đỗ chờ
retryTask(). Thiếu definition thì chặn, không bao giờ tự kết thúc; chỉ một yêu cầu huỷ mới làm taskorphaned.
20pi-env, telemetry và evals#
Chương này gom ba package đứng quanh lõi, mỗi cái trả lời một câu hỏi khác nhau. pi-env (@earendil-works/pi-env): làm sao để tool của một agent Pi Durable chạy trên máy khác, qua SSH, mà kết quả giống hệt khi chạy tại chỗ. pi-telemetry (@earendil-works/pi-telemetry): làm sao để các package của Pi mô tả những gì chúng làm (span, sự kiện (event), metric) mà không buộc vào một backend quan sát nào. pi-evals (packages/evals, không phát hành): làm sao đo hành vi của coding agent, đặc biệt là tài liệu của nó có giúp mô hình làm đúng việc hay không. Cách Pi Durable dùng một ExecutionEnv nằm ở chương Durable: task, scheduler, generation và tool; ở đây ta xem phần hiện thực từ xa.
pi-env: tool chạy ở máy khác, phần bền ở lại#
Bài toán: một worker Pi Durable chạy trên máy của bạn (hay trong một tiến trình server), giữ storage, khoá API và lịch sử hội thoại. Code cần sửa và lệnh cần chạy lại nằm trên một máy khác: một máy GPU, một điện thoại Android chạy Termux, một máy Windows. Mang cả worker sang đó nghĩa là mang theo credential và storage. pi-env tách hai thứ: "tool của agent chạy trên máy khác, thường qua SSH, còn worker Durable, storage và credential của nó ở lại máy cục bộ" (README.md:3–4).
- `pi-env` (`daemon/`): a small Rust program that runs on the remote machine. It speaks a framed protocol on stdin and stdout ([docs/protocol.md](docs/protocol.md)) and performs file operations and commands there.- `RemoteExecutionEnv`: a Durable `ExecutionEnv` that talks to the daemon through a `Connection`. Its results match `NodeExecutionEnv` running on the remote machine; only error messages may differ ([docs/semantics.md](docs/semantics.md)).RemoteExecutionEnv khớp với NodeExecutionEnv chạy trên máy từ xa; chỉ thông báo lỗi được phép khác.Package phụ thuộc Chord (chỉ để lấy kiểu Context) và pi-durable (để lấy giao diện ExecutionEnv và các kiểu kết quả) (package.json:47–50). Bản đầu tiên ra ở 1.0.4 (2026-10-05) với RemoteExecutionEnv, Connection và phần khởi động qua SSH; 1.2.0 thêm listFiles, LocalExecutionEnv và pseudo-terminal (CHANGELOG.md:7–24). Giao diện nó hiện thực thuộc Pi Durable, vốn được ghi là thử nghiệm, nên pi-env cũng di chuyển theo.
Mô hình tư duy: client áp luật của Node, daemon làm system call#
Máy cục bộ Máy từ xa
Harness · task pi.tool · tool bash/read/edit/write
│ ExecutionEnv (FileSystem + Shell)
RemoteExecutionEnv giải đường dẫn, điểm kiểm abort, map mã lỗi,
│ cỡ lần đọc của readFile, luật kết quả của exec
│ request(op, json, payload) · cancel · kill
Connection ── spawn: ssh -T … -- host '~/.pi/mobile/tools/pi-env-<sha>' serve --token <hex>
│ stdin: frame request · cancel · ping ──────▶ pi-env (Rust)
│ stdout: "PI-ENV <token>\n", rồi chỉ frame ◀────── system call · tiến trình · spill
│ watch (cổng của NodeFileWatcher)
storage · credential · gọi mô hình: ở lại listFiles · pty
Connection quản đúng một tiến trình daemon tại một thời điểm; daemon được khởi động lười ở request đầu và khởi động lại sau khi mất. ExecutionEnv của Durable được định nghĩa ở durable/src/env/index.ts:374.Chuẩn đối chiếu là NodeExecutionEnv của Durable chạy trên máy từ xa; bộ kiểm tuân thủ (conformance) cho env của Durable và một test vi sai (differential) kiểm từng luật (semantics.md:3–4). Để đạt điều đó, việc được chia rõ:
## Split of work| Concern | Where ||---|---|| Path resolution: `~` (remote home), `file://`, relative to `cwd`; on Windows drive-relative paths against the remote's working directories | client, the remote system's `path` rules || Abort checkpoints of each method | client, as `NodeExecutionEnv` || Error code mapping (`ENOENT` → `not_found`, ...) | client, as `NodeExecutionEnv`'s `toFileError` || `readFile`'s read sizes, size limit and decoding (`StringDecoder`, byte-order mark kept) over the daemon's `open(path, "r")` | client || `writeFile` in 512 KiB writes with an abort check before each; `appendFile` without checks | client, over one open file || Line reader | client, positional reads and Durable's `StreamDecoder` || Watching: snapshots, native events, polling | daemon, a port of Durable's `NodeFileWatcher` || Timeout validation, result precedence (callback error, timeout, abort, spill failure, exit code) | client || System calls, processes, spill files, line scans | daemon || Command output decoding: per stream, WHATWG UTF-8, only a leading byte-order mark dropped | daemon (`encoding_rs`) |readFile chia lần đọc); daemon làm system call, quản tiến trình, file tràn (spill: toàn bộ output được ghi ra một file tạm khi vượt ngưỡng), quét dòng, và chạy watcher ngay cạnh file.Lý do cách chia này cần thiết chứ không phải tuỳ chọn: nếu daemon tự diễn giải đường dẫn hay tự quyết khi nào kiểm abort, nó sẽ phải sao chép lại hành vi của Node bằng Rust, và mỗi khác biệt nhỏ là một kết quả tool khác đi. Giữ luật ở client, chạy đúng code TypeScript đó, thì chỉ còn system call là phải khớp, và system call được kiểm bằng conformance.
Triển khai qua SSH#
Mọi lần gọi ssh đi qua cùng một bộ tham số, chặt hơn mặc định ở mọi chỗ có thể:
/** * Arguments for `ssh` up to the host: no prompts, no forwarding of any kind, no shared connections, no commands from * the configuration, no locale forwarding (the remote uses its own), and host keys checked strictly against the * application's own file under a fixed alias. */export function sshArguments( target: SshTarget, strictHostKeys = true, knownHostsFile = target.knownHostsFile,): string[] { checkField("host", target.host); checkField("host key alias", target.hostKeyAlias); if (target.user !== undefined) checkField("user", target.user); if (target.port !== undefined && (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535)) { throw new Error(`Invalid port: ${target.port}`); } const option = (setting: string) => ["-o", setting]; return [ ...(target.configFile === undefined ? [] : ["-F", target.configFile]), "-T", "-a", "-x", ...option("BatchMode=yes"), ...option("ClearAllForwardings=yes"), ...option("ForwardAgent=no"), ...option("ForwardX11=no"), ...option("ControlMaster=no"), ...option("ControlPath=none"), ...option("RemoteCommand=none"), ...option("PermitLocalCommand=no"), ...option("SendEnv=-*"), ...option("ServerAliveInterval=15"), ...option(`StrictHostKeyChecking=${strictHostKeys ? "yes" : "accept-new"}`), ...option(`UserKnownHostsFile=${configPath("known hosts file", knownHostsFile)}`), ...option("GlobalKnownHostsFile=none"), ...option("HashKnownHosts=no"), ...option(`HostKeyAlias=${target.hostKeyAlias}`), ...(target.user === undefined ? [] : ["-l", target.user]), ...(target.port === undefined ? [] : ["-p", String(target.port)]), ...(target.identityFile === undefined ? [] : [ ...option(`IdentityFile=${configPath("identity file", target.identityFile)}`), ...option("IdentitiesOnly=yes"), ]), "--", target.host, ];}SendEnv=-* bỏ mọi biến môi trường được gửi, kể cả locale: máy từ xa dùng locale của chính nó. Host key được kiểm theo file known-hosts riêng của ứng dụng dưới một alias cố định, độc lập với alias trong ~/.ssh/config, cổng hay jump host. Host, user và alias bị từ chối nếu bắt đầu bằng - hay chứa khoảng trắng (ssh.ts:59–64).Tin một host key
Vì StrictHostKeyChecking=yes, máy lạ bị từ chối với HostKeyUnknownError. scanHostKey(target) kết nối một lần với accept-new vào một file known-hosts tạm, qua đúng đường mà kết nối thật sẽ đi, rồi trả về các dòng key và fingerprint (qua ssh-keygen -lf); comment ghi rõ hiển thị fingerprint không phải là xác thực, chủ máy phải so với một bản lấy qua kênh khác (ssh.ts:177–207). acceptHostKey chỉ nhận dòng thuần cho đúng alias, và từ chối với HostKeyChangedError nếu key cùng loại đã có mà khác; muốn đổi phải forgetHostKey trước (ssh.ts:253–286). File known-hosts được ghi lại nguyên tử, mỗi file một thay đổi tại một thời điểm (ssh.ts:224–251).
Phát hiện hệ thống và triển khai daemon
detectPlatform() chạy một probe POSIX (uname -s, uname -m, uname -o, $HOME, $TMPDIR, $LD_PRELOAD) qua shell đăng nhập; không được thì thử PowerShell; gặp Git Bash làm shell mặc định của SSH trên Windows thì hỏi lại PowerShell; với Windows, echo %OS% phân biệt cmd.exe với PowerShell. Termux nhận thêm cảnh báo: thiếu TMPDIR, thiếu termux-exec, Android có thể tạm dừng Termux (ssh.ts:288–353). Package chở sẵn daemon cho mọi hệ được hỗ trợ trong bin/pi-env-<platform>-<arch>/ (ssh.ts:355–359).
/** Where a daemon with this content lives on the remote machine: named by its SHA-256, so versions never collide. */function daemonPath(remote: RemotePlatform, sha256: string): string { const name = `pi-env-${sha256.slice(0, 32)}`; return remote.platform === "windows" ? `${remote.home}\\.pi\\mobile\\tools\\${name}.exe` : `${remote.home}/.pi/mobile/tools/${name}`;}/** * Make sure the daemon is on the remote machine, verified by its SHA-256 before it ever runs, and remove daemons of * other contents. The upload goes to a new temporary file and is renamed into place only once its hash matches. * Returns the remote path of the binary. */export async function deployDaemon( target: SshTarget, remote: RemotePlatform, binary = packagedDaemon(remote),): Promise<string> { if (!existsSync(binary)) throw new Error(`No pi-env daemon for ${remote.platform}-${remote.arch} at ${binary}`); const bytes = await readFile(binary); const sha256 = createHash("sha256").update(bytes).digest("hex"); const file = daemonPath(remote, sha256); const state = await checkDaemon(target, remote, file, sha256); if (state === "present") return file; if (state === "nohash") throw new Error(`${target.host} has no sha256sum, shasum or openssl to verify pi-env`); const upload = [ "set -e", POSIX_HASH, `f=${quotePosix(file)}`, 'd=$(dirname "$f")', 'mkdir -p "$d"', 'chmod 700 "$d"', 't=$(mktemp "$d/.pi-env.XXXXXX")', 'cat > "$t"', `if [ "$(hash "$t")" != ${sha256} ]; then rm -f "$t"; echo "pi-env upload is corrupt" >&2; exit 1; fi`, 'chmod 700 "$t"', 'mv -f "$t" "$f"', // Older daemons; running ones keep their file open until they exit. 'for old in "$d"/pi-env-*; do [ "$old" = "$f" ] || rm -f "$old"; done', "echo deployed", ].join("\n"); await runSsh(target, `sh -c ${quotePosix(upload)}`, { stdin: bytes, timeoutMs: UPLOAD_TIMEOUT_MS }); return file;PI-ENV-END, vì sshd của Windows có thể không bao giờ chuyển tiếp lúc stdin kết thúc. Tên file dùng 32 ký tự hex đầu của SHA-256, nên hai phiên bản không bao giờ đè nhau.Kết nối thật là một Connection mà lệnh khởi động là một hàm, chạy lại ở mỗi lần khởi động: phát hiện hệ thống một lần, kiểm (và nếu cần triển khai) daemon, rồi trả về dòng lệnh ssh:
/** A `Connection` whose every start detects the remote system (once), verifies or deploys the daemon, then starts it. */function sshConnectionFor(options: SshConnectOptions, state: SshState): Connection { return new Connection({ command: async () => { if (state.remote === undefined) { const remote = await detectPlatform(options); for (const warning of remote.warnings) options.onLog?.(`${warning}\n`); state.remote = remote; } // A deployment that just verified the binary counts for the first start. const file = state.verified ?? (await deployDaemon(options, state.remote, options.binary ?? packagedDaemon(state.remote))); state.verified = undefined; return [ options.ssh ?? "ssh", ...sshArguments(options), launchCommand(state.remote, file, options.loginShell === true), ]; }, ...(options.onLog === undefined ? {} : { onLog: options.onLog }), });}state.verified), nên connectSsh không kiểm hai lần. Từ lần khởi động thứ hai, kiểm hash là một vòng SSH thêm trước khi chạy daemon.Hai cách lấy kết nối. connectSsh(target) phát hiện và triển khai ngay, và ném lỗi tại chỗ (ví dụ HostKeyUnknownError). sshConnection(target) không làm gì cho tới request đầu tiên; lỗi (mất mạng, host key chưa tin) thành lỗi spawn_error của request đó, và request sau thử lại (ssh.ts:513–539). Cách lười quan trọng với Durable: tạo một môi trường (trong HarnessOptions.env) không bao giờ cần mạng.
Giao thức: dòng đồng bộ, frame và hello#
Client chạy pi-env serve --token <hex> và nói chuyện qua stdin/stdout của tiến trình đó; stderr chỉ là chẩn đoán cho log (protocol.md:3–4). Vấn đề đầu tiên là file khởi động của shell: .bashrc của máy từ xa có thể in một banner lên stdout trước khi daemon chạy. Token giải quyết việc đó: dòng đầu tiên daemon in ra là PI-ENV <token>\n với token lấy từ dòng lệnh (daemon/src/main.rs:472–477), và client bỏ mọi thứ trước dòng đó:
#onData(session: Session, chunk: Buffer): void { if (!session.live) return; session.lastSeen = Date.now(); session.chunks.push(chunk); session.buffered += chunk.length; if (!session.synced) { // Shell startup files may print before the daemon runs; skip everything before its sync line. const marker = Buffer.from(`PI-ENV ${session.token}\n`, "utf8"); const all = session.peek(session.buffered); const index = all.indexOf(marker); if (index === -1) { if (session.buffered > 1024 * 1024) { session.consume(session.buffered - marker.length); } return; } const noise = all.subarray(0, index).toString("utf8").trim(); if (noise !== "") this.#options.onLog?.(noise); session.consume(index + marker.length); session.synced = true; }Sau dòng đó, stdout chỉ còn frame. Mọi frame, cả hai chiều, có cùng khung: u32 length, u8 type, u32 id, u32 jsonLength, JSON, rồi payload thô; số nguyên big-endian, frame tối đa 16 MiB (protocol.md:12–27). Phía client:
/** Lone surrogates become U+FFFD, as Node encodes strings for system calls. */function wellFormed(_key: string, value: unknown): unknown { return typeof value === "string" ? value.toWellFormed() : value;}function frame(type: number, id: number, json: Json, payload: Uint8Array = new Uint8Array(0)): Buffer { const body = Buffer.from(JSON.stringify(json, wellFormed), "utf8"); const header = Buffer.alloc(13); header.writeUInt32BE(9 + body.length + payload.length, 0); header.writeUInt8(type, 4); header.writeUInt32BE(id, 5); header.writeUInt32BE(body.length, 9); return Buffer.concat([header, body, payload]);}toWellFormed() đổi surrogate lẻ thành U+FFFD trước khi mã hoá UTF-8, đúng cách Node mã hoá chuỗi đường dẫn và tham số cho system call. Nội dung file và output đi dạng bytes thô trong payload, không bao giờ qua JSON.Phía daemon, read_frame kiểm độ dài; JSON không parse được vẫn giữ khung nguyên vẹn (frame tới với Value::Null và được trả lời lỗi EINVAL), còn lỗi khung thì kết thúc kết nối (daemon/src/frame.rs:49–82). Sáu loại frame:
| type | Tên | Chiều | Nghĩa |
|---|---|---|---|
| 1 | request | client → daemon | json.op là tên thao tác |
| 2 | result | daemon → client | Request id thành công và kết thúc |
| 3 | error | daemon → client | Request id thất bại và kết thúc: { code, message, syscall?, path? } |
| 4 | event | daemon → client | Tiến độ của request đang chạy (output của exec, pty; thay đổi của watch) |
| 5 | cancel | client → daemon | Huỷ request id; { mode: "kill" } là giết mà không huỷ |
| 6 | ping | cả hai | Còn sống; bên nhận bỏ qua |
Bảng gốc ở protocol.md:28–39. code là mã kiểu Node (ENOENT, EISDIR, ERR_FS_EISDIR...) hoặc một mã thực thi (shell_unavailable, spawn_error, timeout, aborted, unknown); chỉ mã là hợp đồng, thông báo là chẩn đoán. Đường dẫn luôn tuyệt đối: client giải ~, đường dẫn tương đối và URL file:// trước khi gửi.
Request đầu tiên là hello { protocol }; daemon trả về phiên bản giao thức và package, hệ điều hành và kiến trúc theo cách viết của Rust (linux, macos, x86_64, aarch64), thư mục home, tmpdir theo luật os.tmpdir() của Node, thư mục làm việc (kể cả thư mục làm việc theo ổ của Windows), pid, và features: các thao tác thêm vào giao thức 1 sau bản đầu (hiện chỉ listFiles) (main.rs:148–160; protocol.md:71–77). Client từ chối mọi phiên bản khác 1 (connection.ts:309–311), và nodePlatform(info) đổi tên sang cách viết của Node (win32, darwin, x64, arm64) (connection.ts:55–64).
Bên trong daemon: luồng, huỷ, liveness#
Daemon là một chương trình Rust nhỏ, phụ thuộc encoding_rs, ignore (crate của fd và ripgrep), notify, serde_json và libc hoặc windows-sys (daemon/Cargo.toml:8–25). Một luồng đọc frame từ stdin và phân phối:
fn serve_frames(server: &Arc<Server>, input: &mut impl Read) -> io::Result<()> { let jobs = start_workers(); while let Some(request) = frame::read_frame(input)? { match request.kind { REQUEST if matches!( request.json["op"].as_str(), Some("ptyWrite" | "ptyResize" | "ptyForeground") ) => { // Answered here, in arrival order: through the worker pool, two writes could swap. let reply = match request.json["op"].as_str() { Some("ptyWrite") => { pty::write(&server.terminals, &request.json, request.payload) } Some("ptyResize") => pty::resize(&server.terminals, &request.json), _ => pty::foreground(&server.terminals, &request.json), }; server.output.control(match reply { Ok(json) => Frame::new(RESULT, request.id, json), Err(failure) => Frame::new(ERROR, request.id, failure.to_json()), }); } REQUEST => { let id = request.id; // Registered before the request runs, so a cancel right behind it is not lost. let control = Arc::new(Control::default()); server.controls.lock().unwrap().insert(id, control.clone()); // Commands and watchers last; they get their own threads (watchers recurse through trees). if matches!(request.json["op"].as_str(), Some("exec" | "watch" | "pty")) { let worker = server.clone(); let spawned = thread::Builder::new() .stack_size(16 * 1024 * 1024) .spawn(move || respond(&worker, id, request, control)); if let Err(error) = spawned { server.controls.lock().unwrap().remove(&id); let failure = Failure::new("spawn_error", error.to_string()); server .output .bulk(Frame::new(ERROR, id, failure.to_json()), None); } } else { let worker = server.clone(); let serial = match request.json["op"].as_str() { Some("writeChunk") => server.file(&request.json).ok(), _ => None, }; let job: Job = Box::new(move || respond(&worker, id, request, control)); match serial { Some(handle) => run_in_order(&jobs, handle, job), None => { let _ = jobs.send(job); } } } } CANCEL => { let control = server.controls.lock().unwrap().get(&request.id).cloned(); if let Some(control) = control { control.cancel(request.json["mode"].as_str() == Some("kill")); } }exec, watch, pty chạy lâu nên có luồng riêng, với stack 16 MiB (comment ghi watcher đệ quy qua cây thư mục); thao tác file vào một pool 16 luồng, và writeChunk của cùng một handle được xếp hàng theo thứ tự đến (main.rs:377–402).Một Control được đăng ký cho mỗi request trước khi nó chạy, nên một cancel gửi ngay sau request không bao giờ mất (protocol.md:45–51; exec.rs:39–58). Huỷ exec hay pty giết process group của lệnh và kết thúc với aborted; scanLines dừng giữa các khúc, listFiles giữa các mục; watch dừng và trả {}.
Trả lời đi trước output
//! The single writer of stdout. Control frames (results, errors, pings, watch events) always go before bulk command//! output, so a slow link delays output but never a cancel's result or a ping. Bulk output is bounded: readers of//! commands without an output window wait while too much of it is unsent, which slows the command down like a full//! pipe would. pub fn run(&self, writer: &mut impl Write) { loop { let (frame, unsent) = { let mut queues = self.queues.lock().unwrap(); loop { if let Some(frame) = queues.control.pop_front() { break (frame, None); } if let Some((frame, unsent)) = queues.bulk.pop_front() { queues.bulk_bytes -= frame.payload.len(); break (frame, unsent); } if queues.closed { return; } queues = self.queued.wait(queues).unwrap(); } };Liveness: không để lại tiến trình mồ côi
Mỗi bên gửi ping mỗi 5 giây. Daemon giết mọi process group nó đã khởi động và thoát sau 30 giây không nhận được byte nào, hoặc khi stdin kết thúc (main.rs:494–520). Client tính ngược lại: 30 giây im lặng là kết nối đã mất, mọi request đang chờ bị từ chối với { code: "unknown", lost: true }, và request sau khởi động một daemon mới (connection.ts:301–307; protocol.md:62–67). Hệ quả có chủ đích: một điện thoại mất mạng giữa chừng không để lại lệnh nào chạy trên máy từ xa. Với lệnh có hiệu ứng, kết quả lúc đó là không biết: comment của Connection nói thẳng "for mutations their outcome is then unknown" (connection.ts:177–180).
RemoteExecutionEnv: hiện thực ExecutionEnv#
ExecutionEnv của Durable là FileSystem cộng Shell: khoảng hai mươi phương thức file (đọc, ghi, reader nhị phân và theo dòng, đọc thư mục, watch...) và exec cộng cleanup; mọi phương thức trả về Result chứ không ném lỗi, và nhận Context làm tham số cuối (durable/env/index.ts:209–284; chương task giải thích từng phương thức). RemoteExecutionEnv hiện thực đúng giao diện đó; cấu hình gồm connection, id (không gian tên file: hai môi trường cùng id thấy cùng file, ví dụ pi-env:gpu), cwd, và tuỳ chọn shellPath, shellEnv, watch (remote-env.ts:51–61).
Đường dẫn được giải ở client, theo luật của máy từ xa
/** Node's `resolvePath` on the remote system, with its home directory and working directories. */async #resolve(path: string): Promise<string> { const info = await this.connection.info(); const windows = nodePlatform(info).platform === "win32"; const paths = windows ? win32 : posix; let normalized = path; if (normalized === "~") { normalized = info.home; } else if (normalized.startsWith("~/") || (windows && normalized.startsWith("~\\"))) { normalized = paths.join(info.home, normalized.slice(2)); } else if (normalized.startsWith("file://")) { try { normalized = fileURLToPath(normalized, { windows }); } catch { // Keep malformed URLs as ordinary paths, as Node does. } } if (paths.isAbsolute(normalized)) return paths.resolve(normalized); if (windows) { // A drive-relative path on another drive: Node on Windows resolves it against that drive's working directory // (`=D:`), else its own working directory if on that drive, else the drive's root. const drive = /^([a-zA-Z]:)(?![\\/])/.exec(normalized)?.[1]; if (drive !== undefined && drive.toLowerCase() !== this.cwd.slice(0, 2).toLowerCase()) { let base = info.driveCwds[drive.toUpperCase()] ?? info.cwd; if (base.slice(0, 2).toLowerCase() !== drive.toLowerCase() && base[2] === "\\") base = `${drive}\\`; return win32.resolve(base, normalized); } } return paths.resolve(this.cwd, normalized);}path.win32 hay path.posix được chọn theo hello, nên một worker trên macOS giải đúng D:foo của một máy Windows: theo thư mục làm việc của ổ D: (biến =D:), nếu không có thì theo thư mục làm việc của daemon nếu cùng ổ, nếu không thì gốc ổ.Mọi thao tác file đi qua #fileOp: giải đường dẫn, kiểm abort trước, chạy, và với vài thao tác kiểm abort cả sau, đúng các điểm kiểm của NodeExecutionEnv; lỗi được map về FileError (remote-env.ts:404–428). Bảng map mã lỗi là bảng của toFileError trong Node env: ENOENT thành not_found, EACCES/EPERM thành permission_denied, ENOTDIR, EISDIR, EINVAL... còn lại là unknown (errors.ts:8–34).
Handle chết theo daemon
Reader nhị phân, reader theo dòng và reader thư mục giữ một handle số do daemon cấp. Handle chỉ có nghĩa với tiến trình daemon đã mở nó; sau khi kết nối mất và một daemon mới được khởi động, gửi handle cũ tới daemon mới sẽ đọc nhầm file khác. Connection chặn điều đó bằng id của session:
async request(op: string, json: Json, options: RequestOptions = {}): Promise<Reply> { if (options.session !== undefined) { const current = this.#session; if (current === undefined || current.id !== options.session || !current.live) { throw lost("pi-env connection lost"); } return this.#send(current, op, json, options); } const { session } = await this.#connect(); return this.#send(session, op, json, options);}Handle nhớ session của câu trả lời đã cấp nó và gửi mọi request của mình kèm session đó (remote-env.ts:133–158). Daemon giới hạn 4.096 handle mở mỗi kết nối, quá thì EMFILE (main.rs:42–43).Đọc và ghi theo khúc, nhiều khúc cùng bay
Một vòng request qua SSH tốn thời gian, nên đọc tuần tự từng khúc sẽ chậm. readRange() giữ tới 8 lệnh pread cùng bay; một lần đọc ngắn (chạm cuối file) huỷ các giả định của những lần đang bay và đọc tiếp ngay sau nó, đúng như đọc tuần tự (remote-env.ts:94–131). readTextFile/readBinaryFile mở file như open(path, "r") của Node, rồi theo đúng cách readFile của Node chia lần đọc: kích thước 0 (FIFO, thiết bị) thì đọc tuần tự 64 KiB tới hết, không giải mã thì một lần cho file nhỏ hay các khúc 512 KiB, giải mã UTF-8 thì bằng StringDecoder (remote-env.ts:451–524). Ghi đi chiều ngược lại:
/** * Node's `writeFile` (abort checked before each 512 KiB write) or `appendFile` (no checks, one after): the first * request creates parents, opens, and writes the first chunk; later chunks go to the same open file. */async #write(path: string, content: string | Uint8Array, append: boolean, context: Context) { const bytes = typeof content === "string" ? Buffer.from(content, "utf8") : content; const signal = append ? undefined : context.abortSignal; return this.#fileOp( path, context, async (resolved) => { const first = bytes.subarray(0, WRITE_CHUNK); const keep = bytes.length > WRITE_CHUNK; const reply = await this.connection.request("write", { path: resolved, append, keep }, { payload: first }); if (!keep) return; const handle = new Handle(this, reply.json.handle as number, reply.session, resolved); const inFlight: Promise<unknown>[] = []; try { for (let offset = WRITE_CHUNK; offset < bytes.length; offset += WRITE_CHUNK) { if (signal?.aborted) { // A chunk that failed before this check fails the write, as it would have in sequence. const failed = (await Promise.allSettled(inFlight)).find((result) => result.status === "rejected"); if (failed !== undefined) throw failed.reason; throw new FileError("aborted", "The operation was aborted", resolved); } const written = handle.request( "writeChunk", {}, { payload: bytes.subarray(offset, offset + WRITE_CHUNK) }, ); written.catch(() => {}); inFlight.push(written); if (inFlight.length >= WRITE_DEPTH) await inFlight.shift(); } // The first failure; later chunks are refused by the daemon, so the file has no gaps. for (const written of inFlight) await written; } finally { await Promise.allSettled(inFlight); await handle.close(); } }, append ? "both" : "before", );}writeFile kiểm abort trước mỗi khúc 512 KiB như Node; appendFile không kiểm giữa chừng mà kiểm cả trước lẫn sau. Một khúc ghi lỗi làm daemon "đầu độc" handle: các khúc sau bị từ chối EBADF, nên file không bao giờ có lỗ (main.rs:172–182).Một chi tiết nhỏ cho thấy mức độ đòi hỏi "khớp với Node": listDir sắp tên theo bytes trên POSIX như scandir của libuv, dùng bytes gốc (raw) của tên không phải UTF-8; trên Windows thì giữ thứ tự của file system như daemon báo (remote-env.ts:704–710). Các luật mà thư viện chuẩn không tự làm (mkdir đệ quy, rm, mkdtemp, tmpdir, /dev/null, FIFO...) được liệt kê ở semantics.md:21–42.
Lệnh và cửa sổ output#
const controller = new AbortController();const onAbort = () => controller.abort();signal?.addEventListener("abort", onAbort, { once: true });try { const { json } = await this.connection.request( "exec", { ...(typeof command === "string" ? { command } : { argv: [...command] }), cwd, env, inheritEnv, ...(this.#shellPath === undefined ? {} : { shellPath: this.#shellPath }), ...(timeout === undefined ? {} : { timeoutMs: timeout * 1000 }), ...(options?.spill === undefined ? {} : { spill: options.spill }), ...(options?.window === undefined ? {} : { window: options.window }), }, { signal: controller.signal, onStart: (requestId, session) => { running = requestId; this.#running.set(requestId, session); }, onEvent: (event, payload) => { if (settled || callbackError !== undefined || event.kind !== "output") return; const text = Buffer.from(payload.buffer, payload.byteOffset, payload.length).toString("utf8"); if (text === "" || options?.onOutput === undefined) return; const info: ShellOutputInfo = { stream: event.stream === "stderr" ? "stderr" : "stdout" }; if (event.skipped !== undefined) info.skipped = event.skipped as ShellOutputSkip; try { options.onOutput(text, context, info); } catch (error) { const cause = error instanceof Error ? error : new Error(String(error)); callbackError = new ExecutionError("callback_error", cause.message, cause); controller.abort(); } }, }, ); if (callbackError) return err(callbackError); return ok({ exitCode: json.exitCode as number, ...(typeof json.spillPath === "string" ? { spillPath: json.spillPath } : {}), });NodeExecutionEnv làm (timeout hữu hạn, dương, không quá giới hạn của setTimeout) trước khi gửi (remote-env.ts:852–867). Callback onOutput ném lỗi thì lệnh bị huỷ và kết quả là callback_error. Lỗi của daemon được map về ExecutionError: timeout, aborted, shell_unavailable, spawn_error, còn lại unknown, kèm spillPath nếu có (remote-env.ts:928–942).cleanup() dành cho lúc chủ môi trường tắt: nó gửi cancel với mode: "kill" cho mọi lệnh môi trường này còn chạy, giết mà không huỷ, nên lệnh kết thúc với trạng thái của tiến trình bị giết (137), đúng như NodeExecutionEnv (remote-env.ts:950–954; connection.ts:221–225). Phía daemon, ngữ nghĩa lệnh theo semantics.md:44–60: chuỗi chạy qua /bin/bash, which bash, rồi sh với -c, mảng argv chạy thẳng; thư mục làm việc phải tồn tại; môi trường là của daemon, rồi shellEnv, rồi env. Mỗi lệnh có một session (process group) mới với signal mặc định và mask rỗng, nên timeout, abort và cleanup() giết cả cây bằng SIGKILL:
// SAFETY: only async-signal-safe calls between fork and exec. unsafe { command.pre_exec(|| { // A new session, so a kill reaches every descendant; default signal handling and an empty mask, as libuv sets. libc::setsid(); for signal in 1..32 { if signal != libc::SIGKILL && signal != libc::SIGSTOP { libc::signal(signal, libc::SIG_DFL); } } let mut mask: libc::sigset_t = std::mem::zeroed(); libc::sigemptyset(&mut mask); libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut()); Ok(()) }); }}pub fn after_spawn(_child: &Child) {}/// Kill the command's process group, or the process if the group is gone.pub fn kill_tree(pid: u32) { let pid = pid as i32; // SAFETY: plain signal delivery. unsafe { if libc::kill(-pid, libc::SIGKILL) != 0 { libc::kill(pid, libc::SIGKILL); } }}/// A process killed by a signal has no exit code; report 128 + the signal, as the shell does.pub fn exit_code(status: ExitStatus) -> i64 { i64::from( status .code() .unwrap_or_else(|| 128 + status.signal().unwrap_or(0)), )}Sau khi tiến trình thoát, output vẫn được gom cho tới khi cả hai pipe đóng, hoặc 100 ms không có output: một tiến trình con giữ pipe mở không làm lệnh treo mãi (exec.rs:382–385).
Cửa sổ output: không chuyển những gì sẽ bị bỏ
Bài toán: yes | head -c 2G trên máy từ xa sinh hai gigabyte output, trong khi tool bash của Durable chỉ giữ phần đuôi (mặc định 2.000 dòng hoặc 50 KiB cuối, durable/harness/tool.ts:294–296; bash.ts:151) và commit tiến độ mỗi 100 ms. Gửi cả hai gigabyte qua SSH để rồi vứt gần hết là lãng phí, và nó làm nghẽn mọi thứ khác trên link. Giao diện của Durable có sẵn chỗ cho điều này:
spill?: ShellSpillOptions; /** * The caller keeps only this tail of the output, so the environment may omit output outside it and report the omission * as `info.skipped`. Without it, every chunk is delivered. */ window?: ShellOutputWindow;}/** * The tail of the combined output a caller keeps, and how often it samples it. An environment that transfers output * over a slow link uses it to omit what the caller would drop anyway and to send no faster than the caller commits. */export interface ShellOutputWindow { /** UTF-8 bytes of decoded text kept at the end of the output. */ maxBytes: number; /** Lines kept at the end of the output. */ maxLines: number; /** Minimum pause between the caller's samples of the output. */ minIntervalMs: number; /** Each sample also pauses the caller in proportion to its size at this rate. */ bytesPerSecond: number;}ShellOutputInfo.skipped (bytes, newlines, endsWithNewline), và chunk đi sau một lần bỏ luôn chứa nhiều hơn cửa sổ ít nhất một byte hoặc một dòng, nên phần bị bỏ chắc chắn không thể nằm trong phần đuôi được giữ (durable/env/index.ts:347–357).Durable dựng cửa sổ từ giới hạn output của tool (chỉ khi tool giữ phần đuôi) và nhịp commit tiến độ của nó (durable/harness/tool.ts:333–341), tool bash chuyển nó vào exec (durable/tools/bash.ts:104–107), và RemoteExecutionEnv gửi nguyên nó tới daemon. Ở đó, window.rs giữ output chưa giao, gộp nó lại, và thay phần chắc chắn nằm ngoài cửa sổ bằng số đếm:
//! Durable's output window (`ShellExecOptions.window`, `ShellOutputInfo.skipped`): undelivered command output is held//! and coalesced; text followed by more than the window (by a byte or a line) can never be in the caller's tail and is//! replaced by counts. Delivery follows the caller's commit pace, and waits while an earlier frame is still unsent. /// Whether the text after a cut still proves the cut text is outside the window. fn proves(&self, bytes: usize, newlines: usize) -> bool { bytes > self.window.max_bytes || newlines > self.window.max_lines } /// Replace the longest prefix whose remainder still proves exclusion by counts. fn trim(&mut self) { let mut removed = String::new(); /// Deliver everything held: one event carrying the skip and all text after it, or one event per segment. pub fn take(&mut self) -> Vec<Event> { let segments = std::mem::take(&mut self.segments); let delivered = self.bytes; self.bytes = 0; self.newlines = 0; // The caller writes at most the window per sample, and paces itself by what it wrote. let pace = Duration::from_secs_f64( delivered.min(self.window.max_bytes) as f64 / self.window.bytes_per_second, ); self.next_send = Instant::now() + self.window.min_interval.max(pace);skipped cùng toàn bộ text sau nó, rồi hẹn lần giao kế sau max(minIntervalMs, phần đã giao / bytesPerSecond). Vòng lặp của lệnh chỉ giao khi tới hạn và frame trước đã được ghi ra stdout, nên tối đa một frame output của mỗi lệnh đang chờ (exec.rs:386–392).Phía client, event.skipped thành info.skipped của onOutput, tool bash gọi api.output(text, info.skipped), và OutputBuffer.push(chunk, skipped) của Chord cộng số đếm vào tổng và bỏ phần đã lưu trước đó, vì không gì đứng trước chỗ bị bỏ còn có thể nằm trong cửa sổ (chord/src/output.ts:154–182). Kết quả: phần đuôi giữ lại và số byte, số dòng bị bỏ y hệt khi chạy tại chỗ, nhưng chỉ phần đuôi đi qua mạng. Đây là ý của dòng "honors output windows so dropped command output is not transferred" trong changelog bản đầu (CHANGELOG.md:23).
Watch, listFiles và terminal#
Watch chạy cạnh file
Daemon chạy một bản Rust của NodeFileWatcher của Durable ngay cạnh file, nên một lần quét không tốn vòng request nào: sự kiện gốc (inotify, FSEvents) chỉ kích một lần quét lại có debounce, và thay đổi là hiệu giữa hai ảnh chụp cộng các đường dẫn của sự kiện (daemon/src/watch.rs:1–3). Windows và file system mạng hay FUSE thì poll; hết watch gốc thì chuyển sang poll và báo overflow (semantics.md:62–68). Request watch kéo dài tới khi bị huỷ: event ready khi đã phủ xong, rồi change với paths hoặc overflow. Phía client, RemoteWatcher lo phần mất kết nối:
this.#request = request.then( () => {}, (error: unknown) => { if (!ready) { reject(error); return; } if (this.#closed) return; if (isConnectionLost(error)) { void this.#reconnect(); return; } this.#deliver({ error: toFileError(error) }); this.#closed = true; }, );async #reconnect(): Promise<void> { for (let delay = RECONNECT_FIRST_MS; !this.#closed; delay = Math.min(delay * 2, RECONNECT_MAX_MS)) { await new Promise<void>((resolve) => { const timer = setTimeout(resolve, delay); timer.unref(); this.#wake = () => { clearTimeout(timer); resolve(); }; }); this.#wake = undefined; if (this.#closed) return; try { await this.#start(); // Changes while disconnected were not seen. this.#deliver({ overflow: true }); return; } catch (error) { if (isConnectionLost(error)) continue; this.#deliver({ error: toFileError(error) }); this.#closed = true; return; } }}overflow: người dùng watcher phải tự quét lại. Lỗi khác mất kết nối thì kết thúc watcher.listFiles và LocalExecutionEnv
listFiles là phương thức tuỳ chọn của ExecutionEnv: liệt kê cả cây dưới một thư mục như fd --hidden --exclude .git mà không theo symlink, bỏ những gì git ignore bên trong work tree, có giới hạn maxEntries (mặc định 50.000), maxBytes (4 MiB) và timeoutMs (10 s) với cờ truncated (protocol.md:101–112). Daemon dùng crate ignore của fd và ripgrep (daemon/src/list.rs:1–2), và trả kết quả dạng bytes chứ không phải JSON:
const aborted = abortResult<FileListing>(context.abortSignal, path);if (aborted) return aborted;try { if (!(await connection.info()).features?.includes("listFiles")) { return err(new FileError("not_supported", "The pi-env daemon does not list files; update it", path)); } const { json, payload } = await connection.request( "listFiles", // Fields left undefined are not sent; the daemon applies the defaults. { path, hidden: options?.hidden, gitignore: options?.gitignore, exclude: options?.exclude, maxEntries: options?.maxEntries, maxBytes: options?.maxBytes, timeoutMs: options?.timeoutMs, }, context.abortSignal ? { signal: context.abortSignal } : {}, ); const afterAbort = abortResult<FileListing>(context.abortSignal, path); if (afterAbort) return afterAbort; // Records of a kind byte, the UTF-8 path, and a NUL. const entries: FileListEntry[] = []; const bytes = Buffer.from(payload.buffer, payload.byteOffset, payload.length); for (let start = 0; start < bytes.length; ) { const end = bytes.indexOf(0, start); if (end === -1) break; const kind = KINDS[bytes[start]!]; if (kind !== undefined) entries.push({ path: bytes.toString("utf8", start + 1, end), kind }); start = end + 1; } return ok({ entries, truncated: json.truncated === true });} catch (error) { return err(toFileError(error, path));}listFiles không có tên đó trong hello.features, nên client trả not_supported mà không gửi request, và người gọi tự duyệt cây (durable/README.md:456–460).Vì NodeExecutionEnv không có listFiles, pi-env thêm LocalExecutionEnv: NodeExecutionEnv cộng listFiles qua một daemon trên chính máy này (new Connection({ command: [localDaemon()] })), để môi trường cục bộ và từ xa liệt kê bằng cùng một code (list-files.ts:79–102). Connection thuộc về người gọi, và người gọi đóng nó.
Terminal
openTerminal(connection, options) chạy một chương trình (mặc định shell đăng nhập của user) trong một pseudo-terminal của daemon, dẫn đầu một session mới với terminal làm terminal điều khiển; output là bytes thô của terminal, không giải mã như exec (protocol.md:134–151). RemoteTerminal có write() (chia input thành các khúc 1 MiB vì frame tối đa 16 MiB), resize() (chương trình nhận SIGWINCH), close() (giết cả session), và foreground() trả về process group đang ở tiền cảnh khi nó không phải chính chương trình, như một trình soạn thảo mà shell đang chạy (terminal.ts:26–90). ptyWrite, ptyResize, ptyForeground đánh địa chỉ request pty đang chạy bằng id của nó, và được trả lời theo thứ tự đến (đã thấy ở mục daemon). Terminal chưa hỗ trợ Windows (ENOTSUP).
Lần theo một lệnh bash trên máy từ xa#
Kịch bản: một Harness của Pi Durable chạy trên laptop, môi trường là new RemoteExecutionEnv({ connection: sshConnection(target).connection, id: "pi-env:gpu", cwd: "/home/me/project" }) như ví dụ của README (README.md:25–28), host key của gpu-box đã được tin, và đây là lệnh đầu tiên của tiến trình. Mô hình gọi tool bash với { "command": "pytest -q" } (lệnh là giả định). Các bước theo code, không phải in ra từ một lần chạy.
- 1. Tool bashTask
pi.toolcủa Durable chạyexecutecủa toolbash, vốn gọienv.exec(command, { cwd, env, inheritEnv, timeout, onOutput, spill, window }, context);windowlà đuôi 2.000 dòng / 50 KiB và nhịp 100 ms (bash.ts:95–110). - 2. Kiểm ở client
RemoteExecutionEnv.execkiểm timeout, giảicwdtheo luật của máy từ xa, dựng biến môi trường (shellEnvrồienvkhi kế thừa), rồi gọiconnection.request("exec", …)vớionStart(đểcleanup()biết lệnh này) vàonEvent. - 3. Khởi động lườiChưa có session, nên
#start()gọi hàm lệnh củasshConnectionFor:detectPlatform()(một vòng SSH),deployDaemon()kiểm hash ở xa và nếu thiếu thì tải lên, rồi trả về["ssh", …sshArguments(target), "'/home/me/.pi/mobile/tools/pi-env-<32 hex>'"].Connectionspawn nó vớiserve --token <hex>(connection.ts:268–283). - 4. Đồng bộ và hello
.bashrcở xa in một dòng chào; client chuyển nó vàoonLogkhi thấyPI-ENV <token>. Request đầu làhello { protocol: 1 }; thông tin máy (home, tmpdir, hệ điều hành) được nhớ cho mọi lần giải đường dẫn sau. - 5. Request exec
#sendcấp id, đăng ký pending, gắn listener huỷ (abort của Context thành một framecancel), rồi ghiframe(REQUEST, id, { …json, op: "exec" })vào stdin củassh(connection.ts:240–262). - 6. Daemon chạy lệnh
serve_framesđăng kýControl, choexecmột luồng riêng;exec::runchọn shell (/bin/bash -c "pytest -q"), kiểmcwdtồn tại, spawn vớisetsid, ghi pid vào tập process group, và mở hai luồng đọc pipe (exec.rs:242–320). - 7. Output qua cửa sổBytes từ pipe được giải mã UTF-8 theo từng luồng, đẩy vào
Pending; phần chắc chắn nằm ngoài đuôi được thay bằng số đếm. Tới hạn và frame trước đã ra stdout thì một event{ kind: "output", stream, skipped? }mang text làm payload được xếp vào hàng bulk. - 8. Về tới clientNgười ghi stdout của daemon gửi frame control trước, bulk sau;
#onDatatách frame,#dispatchgọionEvent,RemoteExecutionEnvgọionOutput(text, context, { stream, skipped }), toolbashgọiapi.output(text, skipped), vàOutputBuffercủa Chord giữ đuôi;Progressquyết định khi nào commit phần đã giữ vào storage (chương task). - 9. Kết quảTiến trình thoát; daemon gom output tới khi hai pipe đóng hoặc 100 ms im lặng, giao phần còn giữ, rồi gửi
result { exitCode }qua hàng bulk, nên nó tới sau output của chính lệnh đó.exectrảok({ exitCode }); tool dựng kết quả từ output đã giữ (bash.ts:126–135).
Ví dụ minh hoạ: các frame trên stdin/stdout của ssh (id và kích thước là giả định; ping bị lược)
stdout "Welcome to gpu-box\n" → onLog, bỏ qua
stdout "PI-ENV 3f9c…e1\n" → synced
stdin [len][1][id 1][jsonLen]{"protocol":1,"op":"hello"}
stdout [len][2][id 1][jsonLen]{"protocol":1,"version":"1.4.0","os":"linux","arch":"x86_64",
"home":"/home/me","tmpdir":"/tmp",…,"features":["listFiles"]}
stdin [len][1][id 2][jsonLen]{"command":"pytest -q","cwd":"/home/me/project","env":{},
"inheritEnv":true,"spill":{…},"window":{"maxBytes":51200,
"maxLines":2000,"minIntervalMs":100,"bytesPerSecond":102400},"op":"exec"}
stdout [len][4][id 2][jsonLen]{"kind":"output","stream":"stdout"} payload: "....F.\n"
stdout [len][4][id 2][jsonLen]{"kind":"output","stream":"stdout"} payload: "1 failed, 5 passed\n"
stdout [len][2][id 2][jsonLen]{"exitCode":1} → kết quả lỗi exit_code của tool
frame(): u32 độ dài, u8 loại, u32 id, u32 độ dài JSON, JSON, payload. Thứ tự khoá JSON là thứ tự JSON.stringify của { ...json, op }.Nếu Wi-Fi rớt giữa chừng: sau 30 giây không nhận được byte nào, daemon giết process group của pytest và thoát; client, cũng sau 30 giây im lặng, huỷ session và từ chối request exec với { code: "unknown", lost: true }. exec trả err(ExecutionError("unknown", …)), tool bash ném lại lỗi đó, và Durable biến nó thành một kết quả lỗi vẫn mang output đã giữ (bash.ts:138–143). Lệnh tiếp theo khởi động một daemon mới (kiểm lại hash, không tải lại nếu đã có). Nếu chính tiến trình Durable chết thì lại là chuyện khác: pipe stdin của ssh đóng theo nó, và giao thức hứa daemon giết mọi thứ khi stdin kết thúc; nếu EOF không tới được máy từ xa thì giới hạn 30 giây im lặng vẫn dọn dẹp (protocol.md:64–65; đường đi của EOF qua ssh là suy luận, không có test trong repo). Khi mở lại, Durable xử lý lời gọi tool bị cắt ngang theo chính sách replay của tool (chương task).
pi-telemetry: hợp đồng span, sự kiện, metric#
Bài toán: pi-ai, pi-agent-core và Pi Durable đều muốn mô tả việc chúng làm (một request tới mô hình mất bao lâu, một phase của task kết thúc ra sao), nhưng người dùng của chúng dùng backend quan sát khác nhau: OpenTelemetry, Sentry, log thuần, hay không gì cả. Gắn một SDK cụ thể vào thư viện là bắt mọi người theo nó. pi-telemetry là "vendor-neutral telemetry contracts and typed schema utilities": một hợp đồng span dựa trên callback, một context no-op dùng chung, propagation qua tiến trình, metric, một adapter tham chiếu trong bộ nhớ, schema có kiểu, và không có exporter, không có span "hiện tại" toàn cục, không phụ thuộc backend nào (README.md:3–15). Package được tách ra ở commit 6b461b75b "feat: extract telemetry package" (2026-08-05); bản 1.4.0 thêm propagation, link, metric, TelemetryStore và renderTrace (CHANGELOG.md:3–20).
Từ vựng theo bảng của README (README.md:48–55): span là bản ghi có thời gian của một thao tác; span lồng nhau thành cây; attribute là một sự kiện có tên gắn vào span (cache.hit: true); event là một điểm trong span, không có độ dài; status là ok hoặc error; context là nơi một span mới sẽ được gắn vào cây. Span là dữ liệu chẩn đoán, không phải state nghiệp vụ.
export type AttributeValue = string | number | boolean | readonly string[] | readonly number[] | readonly boolean[];export interface SpanAttributes { [name: string]: AttributeValue | undefined;}/** Opaque, JSON-serializable adapter-owned propagation data. */export type TelemetryCarrier = Record<string, string>;/** Optional transport capability implemented by the application's telemetry adapter. */export interface TelemetryPropagation { inject(context: TelemetryContext): TelemetryCarrier | undefined; /** Absent or unknown carriers use the root; recognized identifiers are opaque strings. */ extract(carrier: TelemetryCarrier | undefined): TelemetryContext;}export interface SpanLink { context: TelemetryContext; attributes?: SpanAttributes;}export interface SpanOptions { name: string; attributes?: SpanAttributes; links?: readonly SpanLink[];}export type SpanStatus = { status: "ok" } | { status: "error"; error?: { name: string; message: string } };/** Adapter contract for trusted typed inputs; malformed input behavior is unspecified. */export interface TelemetryContext { /** Adapter capabilities remain available on child spans and extracted parents. */ readonly metrics?: TelemetryMetrics; readonly propagation?: TelemetryPropagation; startSpan<T>(options: SpanOptions, callback: (span: TelemetrySpan) => T | Promise<T>): Promise<T>;}export interface TelemetrySpan extends TelemetryContext { addLink(link: SpanLink): void; addEvent(name: string, attributes?: SpanAttributes): void; setAttributes(attributes: SpanAttributes): void; setStatus(status: SpanStatus): void;}TelemetrySpan cũng là một TelemetryContext: span con được mở từ chính span cha, nên quan hệ cha con đi theo tham số chứ không theo một ngữ cảnh ngầm. Giá trị attribute chỉ là số, chuỗi, boolean hoặc mảng của chúng.Không có end(). startSpan(options, callback) giữ span mở cho tới khi giá trị hay promise của callback xong; thành công là ok, ném hay reject là error trừ khi đã đặt status tường minh (README.md:108–121). Hợp đồng của một adapter được liệt kê thành từng điều: gọi callback đồng bộ đúng một lần, giữ nguyên giá trị trả về và giá trị reject, gộp setAttributes (giá trị sau thay giá trị trước, undefined bị bỏ qua), các phương thức ghi là đồng bộ, và bỏ qua mọi lời gọi sau khi span đã xong (README.md:127–139). Đầu vào có kiểu được tin; adapter không cần kiểm hay nuốt lỗi của chính nó (CHANGELOG 1.4.0, CHANGELOG.md:7).
No-op và adapter trong bộ nhớ
async function startNoopSpan<T>(_options: SpanOptions, callback: (span: TelemetrySpan) => T | Promise<T>): Promise<T> { return callback(noopTelemetrySpan);}const noopCounter: TelemetryCounter = Object.freeze({ add: () => {} });const noopUpDownCounter: TelemetryUpDownCounter = Object.freeze({ add: () => {} });const noopHistogram: TelemetryHistogram = Object.freeze({ record: () => {} });/** Shared instruments that never inspect or retain metadata, measurements, or contexts. */export const NOOP_TELEMETRY_METRICS: TelemetryMetrics = Object.freeze({ counter: () => noopCounter, upDownCounter: () => noopUpDownCounter, histogram: () => noopHistogram,});/** Shared propagation capability that neither inspects nor retains its inputs. */export const NOOP_TELEMETRY_PROPAGATION: TelemetryPropagation = Object.freeze({ inject: () => undefined, extract: () => NOOP_TELEMETRY_CONTEXT,});const noopTelemetrySpan: TelemetrySpan = { metrics: NOOP_TELEMETRY_METRICS, propagation: NOOP_TELEMETRY_PROPAGATION, startSpan: startNoopSpan, addLink: () => {}, addEvent: () => {}, setAttributes: () => {}, setStatus: () => {},};Object.freeze(noopTelemetrySpan);/** Shared telemetry context used when an application does not provide one. */export const NOOP_TELEMETRY_CONTEXT: TelemetrySpan = noopTelemetrySpan;async là có chủ đích: một lần ném đồng bộ trong callback thành một promise bị reject với đúng giá trị đó. Đây là span mà BACKGROUND_CONTEXT của Chord trả về khi không có span nào (chương Chord).InMemoryTelemetryContext là hiện thực tham chiếu, dùng cho test, chẩn đoán cục bộ, hoặc ứng dụng muốn ghi lại trong tiến trình mà không cần exporter. Nó cài đặt cùng lúc TelemetryContext, TelemetryPropagation, TelemetryMetrics và TelemetryStore, ghi span với id số tăng dần, và trả về ảnh chụp đã tách khỏi bộ nhớ của nó (memory.ts:282–299). Phần quyết định status và kết thúc:
async function startInMemorySpan<T>( state: InMemoryTelemetryState, parent: InMemoryParent | undefined, options: SpanOptions, callback: (span: TelemetrySpan) => T | Promise<T>,): Promise<T> { if (parent?.span?.settled) return startInertSpan(state, options, callback); const recordedSpan = createSpan(state, parent, options); state.spans.push(recordedSpan); contextCarriers.set(span, recordedSpan.context); try { return await callback(span); } catch (error) { if (!recordedSpan.explicitStatus) recordedSpan.status = error instanceof Error ? { status: "error", error: { name: error.name, message: error.message } } : { status: "error" }; throw error; } finally { recordedSpan.settled = true; recordedSpan.endedMs = recordedSpan.startedMs + performance.now() - recordedSpan.clock; recordedSpan.endSequence = state.nextEndSequence++; }}Propagation, link và metric
Để một trace đi qua ranh giới tiến trình, adapter có thể cài đặt TelemetryPropagation: inject(context) trả về một carrier, một map chuỗi sang chuỗi an toàn với JSON mà định dạng do adapter quyết (W3C traceparent cũng vừa), và extract(carrier) trả về một context cha ở phía nhận; carrier lạ hay vắng thì rơi về gốc. Transport chỉ chở carrier, không bao giờ chở context hay span; và metadata của trace là chẩn đoán, không bao giờ là phân quyền (README.md:143–167). Adapter trong bộ nhớ dùng ba khoá pi-memory-scope, pi-memory-span, pi-memory-trace, nên carrier của nó sống qua JSON và qua các tiến trình độc lập (memory.ts:307–324). Link nối hai đoạn công việc mà không đổi cha, ví dụ một activation với mọi lời gọi đã kích nó (README.md:169–181).
Metric (counter, up-down counter, histogram) là một năng lực tuỳ chọn của cùng adapter, lộ ra qua context.metrics, để một lời gọi mô hình lồng sâu vẫn ghi được metric mà không cần cấu hình riêng (metrics.ts:10–41). Histogram nhận thêm một context để gắn phép đo với span (exemplar). Quy tắc: chỉ dùng thuộc tính có ít giá trị (hướng, phương thức), không dùng id request, id người dùng, prompt hay đường dẫn tuỳ ý (README.md:205–209).
Đọc lại: TelemetryStore và renderTrace
TelemetryStore tách truy vấn khỏi ghi nhận: findTraces(query) (trace mới nhất trước, lọc theo tên span sớm nhất, khoảng thời gian, errors, thuộc tính, phân trang bằng cursor), getTrace(traceId), findMetrics(query) (index.ts:90–95). Hai hiện thực: adapter trong bộ nhớ và telemetry-sqlite của Chord (chương Chord). Store chỉ trả span đã xong; trace id là span id của gốc, nên span từ nhiều store được gộp theo trace id, và quan hệ cha con, không phải việc thời gian nằm lồng nhau, định nghĩa cây (README.md:289–297). renderTrace(spans) in một rừng span theo thời gian, coi cha vắng mặt là gốc, kèm thuộc tính, thời lượng, lỗi, offset của event và link (render.ts:9–49).
Bộ conformance
@earendil-works/pi-telemetry/testing xuất createTelemetryAdapterConformance(factory): 14 ca kiểm độc lập với test runner, mỗi ca một fixture mới; fixture cung cấp context và đổi span đã xong của backend thành RecordedTelemetrySpan chuẩn hoá (testing/types.ts:4–21). Bộ ca kiểm năng lực được mang qua mọi context, việc nhận callback đồng bộ đúng một lần, danh tính kết quả và lỗi, status tự động và tường minh, gộp thuộc tính, thứ tự event, lời gọi trơ sau khi xong, quan hệ cha con lồng và đồng thời, link; với fixture có propagation và metrics thì kiểm thêm vòng JSON, nhiều chặng adapter, và thứ tự phép đo (README.md:341). Đây là cách một adapter OpenTelemetry hay Sentry do người khác viết chứng minh nó tuân hợp đồng.
Schema telemetry có kiểu#
API tầng thấp cố ý mở: tên span là chuỗi bất kỳ, thuộc tính là một túi bất kỳ, để adapter dùng chung được. Package miền (pi-ai, Durable) muốn ngược lại: một từ vựng đóng, để mọi chỗ phát ra đúng tên và đúng thuộc tính. Schema là dữ liệu JSON tuần tự hoá được, mô tả từng span (mô tả, quy tắc cha, thuộc tính lúc bắt đầu kèm required, thuộc tính lúc hoàn tất, event, quy tắc status) và từng metric (index.ts:128–177). defineTelemetrySchema() chỉ là hàm định danh có kiểu: nó trả lại chính đối số và không kiểm gì lúc chạy.
function bindTypedSpanStarter<Schemas extends TelemetrySchemaTuple>( telemetryContext: TelemetryContext,): TypedSpanStarter<Schemas> { const startSpan = ( name: SpanNameInSchemas<Schemas>, attributes: SpanAttributes, callback: ( span: SpanInSchema<Schemas[number], SpanNameInSchemas<Schemas>>, startChildSpan: TypedSpanStarter<Schemas>, ) => unknown, ): Promise<unknown> => telemetryContext.startSpan({ name, attributes }, (span) => callback( span as SpanInSchema<Schemas[number], SpanNameInSchemas<Schemas>>, bindTypedSpanStarter<Schemas>(span), ), ); return startSpan as TypedSpanStarter<Schemas>;}/** * Bind an explicit parent context to the combined span vocabulary of one or more schemas. * Schema values are used only for type inference; no runtime schema validation is performed. */export function createTypedSpanStarter<const Schemas extends TelemetrySchemaTuple>( telemetryContext: TelemetryContext, _schemas: Schemas & UniqueTelemetrySchemas<Schemas>,): TypedSpanStarter<Schemas> { return bindTypedSpanStarter<Schemas>(telemetryContext);}UniqueTelemetrySchemas).startAttributes và endAttributes chỉ nói khi nào thuộc tính thường đã biết; cả hai thành thuộc tính thường của cùng một span, và "end" nghĩa là làm giàu lúc hoàn tất, luôn tuỳ chọn, vì một đường lỗi sớm hay huỷ có thể không bao giờ có giá trị đó (README.md:424–453). createTypedMetrics(metrics, schemas) làm việc tương tự cho metric, khác ở chỗ nó đọc và giữ metadata của instrument (mô tả, đơn vị) lúc chạy để chuyển cho adapter; nó vẫn không kiểm thuộc tính lúc chạy (metrics.ts:128–145).
Pi Durable và telemetry#
Host đưa một adapter vào HarnessOptions.telemetry; vắng thì là no-op (durable/harness/types.ts:609–611). Durable dùng nó cho hai span ngắn và vài metric, mô tả bằng DURABLE_TELEMETRY_SCHEMA. Lời hứa quan trọng nhất nằm ở README: thuộc tính chỉ chứa ID và metadata điều khiển, không bao giờ chứa message, đối số hay kết quả; ID trỏ vào transcript (durable/README.md:82).
/** Durable's content-free span, event, and metric vocabulary. IDs address durable records, never content. */export const DURABLE_TELEMETRY_SCHEMA = defineTelemetrySchema({ version: 1, spans: { "pi.durable.phase": { description: "One task phase invocation, including its decision commit, never a wait or restart.", parents: { kind: "any" }, startAttributes: { "task.id": { type: "number", required: true, description: "Task ID.", cardinality: "high" }, "task.name": { type: "string", required: true, description: "Registered task name." }, phase: { type: "string", required: true, description: "Invoked checkpoint phase." }, kind: { type: "string", required: true, values: ["run", "abort"], description: "Invocation kind." }, attempt: { type: "number", required: false, description: "Checkpoint attempt, when present." }, resumed: { type: "boolean", required: true, description: "First invocation of a task loaded in this process.", }, }, endAttributes: { outcome: { type: "string", description: "next=<phase>, waiting, sleeping, done, faulted, or aborted." }, "tool.name": { type: "string", description: "Called tool name." }, "tool.call_id": { type: "string", description: "Transcript tool call ID.", cardinality: "high" }, }, events: { "pi.durable.entry": entry, "pi.durable.submission": submission, "pi.durable.report": report, "pi.durable.checkpoint": { description: "The running checkpoint's phase changed while the invocation continued.", attributes: { phase: { type: "string", required: true, description: "Committed checkpoint phase." } }, }, "pi.durable.replay": { description: "A nested tool call was answered from its stored result.", attributes: { "tool.name": { type: "string", required: true, description: "Called tool name." } }, }, }, status: { default: "ok", errorWhen: "Faulted, failed, and orphaned outcomes use task_faulted, task_failed, and task_orphaned with a diagnostic capped at 500 characters. Aborted outcomes keep status ok.", }, },pi.durable.submit, bao việc nhận một submission công khai và commit của nó; input mở trace gốc liên kết với người gọi, write là con của người gọi (telemetry.ts:53–76). Ba metric: pi.durable.reports (counter theo loại report), pi.durable.phase.duration (histogram, ms), pi.durable.tasks.active (up-down counter) (telemetry.ts:117–138).Span phase được mở trong scheduler. Đoạn đầu cho thấy hai ý thiết kế cùng lúc:
/** A phase owns its decision commit, not the next handler, a durable wait, or recovery. */#phase<T>(invocation: Invocation, task: ErasedRunningTask, run: () => Promise<T>): Promise<T> { const parent = task.trace === undefined ? this.#telemetry : (this.#telemetry.propagation?.extract(task.trace) ?? this.#telemetry); const checkpoint = task.state.checkpoint; const attempt = checkpoint.attempt; const resumed = this.#resumed.delete(task.id); return createTypedSpanStarter(parent, [DURABLE_TELEMETRY_SCHEMA])( "pi.durable.phase", { "task.id": task.id, "task.name": task.kind, phase: checkpoint.phase, kind: invocation.mode, resumed, ...(attempt === undefined ? {} : { attempt }), }, async (span) => { if (span.propagation !== undefined && invocation.links !== undefined) { for (const carrier of invocation.links) span.addLink({ context: span.propagation.extract(carrier) }); } const previous = invocation.context; invocation.context = withTelemetry(span, previous); const started = performance.now(); try {outcome, đặt status lỗi với tên task_faulted, task_failed, task_orphaned và thông báo cắt còn 500 ký tự, rồi ghi thời lượng vào histogram kèm span làm exemplar (scheduler.ts:1766–1802).Ý thứ nhất: cha của span không phải context đang chạy mà là task.trace, một carrier được inject lúc task được tạo và lưu trong bản ghi task (durable/types.ts:646–650; tạo ở transaction.ts:435–438). Một task chạy qua nhiều lần khởi động lại, ở nhiều tiến trình khác nhau; nhờ carrier nằm trong storage, phase chạy sau một lần crash vẫn gắn vào trace của nơi đã tạo ra task. README của pi-telemetry cho phép đúng điều này: context và span không bao giờ được lưu, nhưng một carrier đã inject có thể được lưu làm dữ liệu tương quan chẩn đoán tuỳ chọn (README.md:515). Submission cũng lưu carrier của span admission (submissions.ts:219). Ý thứ hai: withTelemetry(span, previous) của Chord đặt span vào Context của invocation, nên mọi thứ phase gọi xuống (pi-ai, tool) đều nhận span làm cha qua tham số, không qua ngữ cảnh ngầm.
Nguyên tắc "không chứa nội dung" được giữ cả ở đường lỗi: khi admission thất bại, span chỉ nhận status error mà không có thông báo, vì "Error text may contain caller content" (submissions.ts:93–97). Còn lưu trace ở đâu là việc của host: một adapter OpenTelemetry, hoặc createSqliteTelemetry(db) của Chord trên chính database SQLite của ứng dụng. Theo README, pi-ai nhận một TelemetrySpan cha qua tuỳ chọn telemetry của request và xuất AI_TELEMETRY_SCHEMA; pi-agent-core giữ instrumentation và schema riêng của nó (README.md:497–511).
pi-evals: đo hành vi của coding agent#
packages/evals là "behavioral evals for Pi's coding agent, built with vitest-evals" (README.md:3). Nó là package riêng tư, không phát hành (package.json:4), xuất hiện từ commit eafe11fb9 "feat(coding-agent): add vitest eval harness (#7085)" (2026-07-25). Đối tượng được đo là coding agent (AgentSession, xem chương AgentSession), không phải Pi Durable, và mỗi lần chạy gọi một mô hình thật: cần PI_PROVIDER và PI_MODEL cùng credential trong ~/.pi/agent/auth.json (README.md:22–28; docker.ts:67–89).
Hai loại eval
- Host eval (
*.eval.ts), chạy bằng Vitest ngay trên máy (npm run eval:host):smoke.eval.tshỏi thủ đô của Pháp vớinoTools: "all"và kiểm câu trả lời, lỗi, provider, số token (smoke.eval.ts:5–18);documentation-audit.eval.tscho agent đối chiếu từng trang trongpackages/coding-agent/docsvới code, và nộp kết luậnmatch/mismatch/inconclusivequa một tool tuỳ chỉnh có schema chặt (documentation-audit.eval.ts:9–65). - Eval độ nâng của tài liệu (documentation lift,
*.docs.eval.ts): mỗi ca chạy trong hai container cô lập,without_docsvàwith_docs, và báo cáo chênh lệch tỉ lệ đạt (README.md:5–10). Năm bộ ở v1.4.0: custom provider, extension, model, provider OpenAI, TUI.
Một eval độ nâng trông như thế này: cấu hình một mô hình mới cho Pi đang chạy, nạp lại, rồi so cấu hình thật với kết quả mong đợi bằng một judge khớp chặt:
const harness = createPiDocumentationEvalHarness({ output: ({ session }) => inspectAddedModel(session.modelRuntime, PROVIDER_ID, MODEL_ID),});const judge = StructuredOutputJudge({ expected, match: "strict", allowExtras: false });describeEval("Add model to existing provider", { harness, judges: [judge], judgeThreshold: null }, (it) => { it("adds the model without replacing existing models", async ({ run }) => { await run([ { type: "prompt", content: `Configure this running Pi installation with a new \`${PROVIDER_ID}/${MODEL_ID}\` model. Do not create project-local configuration. Show it as “${MODEL_NAME}”. It accepts text, supports reasoning, has a 32,768-token context window and a 4,096-token maximum output, and has no usage cost.`, }, { type: "reload" }, ]); });});judgeThreshold: null nghĩa là điểm thấp là dữ liệu, không phải lỗi hạ tầng; assertion của Vitest chỉ dành cho bất biến của bộ test bị vỡ (README.md:127–129).Harness: một coding agent cô lập cho mỗi lần chạy
createPiCodingAgentHarness() là bộ chuyển đổi cho vitest-evals. Mỗi lần chạy, runPiCodingAgent() tạo một thư mục gốc tạm với workspace, home và thư mục agent riêng, đổi HOME và PI_CODING_AGENT_DIR sang đó, nạp đúng credential của provider đã chọn vào một InMemoryCredentialStore, gieo file fixture vào workspace (từ chối đường dẫn thoát ra ngoài), dựng session với thinkingLevel: "off", và từ chối nếu có extension nào ngoài những extension inline mà chính harness thêm (harness.ts:272–365). Sau khi chạy, nó gom transcript thành sự kiện, usage (token, lời gọi tool, chi phí ước tính nếu mô hình có giá), hash SHA-256 của system prompt, và lưu file session.jsonl làm artifact (harness.ts:390–431). Biến thể tài liệu được dựng ở đây:
/** Documentation evals intentionally exclude shell and unrestricted network tools. */export const DOCUMENTATION_EVAL_TOOLS = ["read", "write", "edit", "grep", "find", "ls"] as const;export function resolveDocumentationVariant( value: string | undefined = process.env.PI_EVAL_VARIANT,): DocumentationVariant { if (value === "without_docs" || value === "with_docs") return value; throw new TypeError('PI_EVAL_VARIANT must be "without_docs" or "with_docs".');}export function excludePiDocumentation(defaultPrompt: string): string { const documentationStartMarker = "\n<docs>\n"; const documentationEndMarker = "\n</docs>"; const documentationStart = defaultPrompt.indexOf(documentationStartMarker); if (documentationStart === -1) throw new Error("Default Pi system prompt has no Pi documentation section."); const documentationEnd = defaultPrompt.indexOf(documentationEndMarker, documentationStart); if (documentationEnd === -1) throw new Error("Default Pi system prompt has no complete Pi documentation section."); const cwdStart = defaultPrompt.lastIndexOf("\n<cwd>\n"); if (cwdStart < documentationEnd) throw new Error("Default Pi system prompt has no working-directory section."); return ( defaultPrompt.slice(0, documentationStart) + defaultPrompt.slice(documentationEnd + documentationEndMarker.length) );}export function createPiDocumentationEvalHarness<TOutput extends JsonValue>( options: DocumentationHarnessOptions | DocumentationHarnessWithOutput<TOutput> = {},) { if (process.env.PI_EVAL_CONTAINER !== "1" || !resolveSandboxIdentity()) { throw new Error("Documentation evals must run in the isolated container sandbox."); } const variant = resolveDocumentationVariant(); return createPiCodingAgentHarness({ ...options, name: variant, tools: options.tools ?? [...DOCUMENTATION_EVAL_TOOLS], ...(variant === "without_docs" ? { transformSystemPrompt: excludePiDocumentation } : {}), expectedPiDocumentation: variant === "with_docs", });}without_docs còn xoá hẳn README.md, CHANGELOG.md, docs/ và examples/ của coding agent (README.md:82–90). verifySystemPrompt kiểm prompt thật của mỗi lần chạy có đúng biến thể không (harness.ts:257–270).Container: mô hình không đọc được bài chấm
Một eval có thể bị "giải" bằng cách đọc judge hay fixture. Thiết kế chặn điều đó bằng quyền file. Dockerfile build workspace một lần, dựng hai target without_docs và with_docs từ cùng một runtime đã cài, và đặt mọi mã nguồn của evaluator ở chế độ chỉ root đọc được (docker/Dockerfile:12–44). Entrypoint kiểm nội dung image theo danh sách cho phép, kiểm tài liệu của package nội bộ không lộ cho mô hình, chạy thử một tiến trình với UID của sandbox để chắc nó không đọc được mã evaluator, và kiểm coding agent được nạp từ dist/ (docker/entrypoint.ts:27–105). Bên trong lần chạy, harness tự hạ quyền vĩnh viễn trước khi gửi prompt:
async function enterToolSandbox(root: string, identity: SandboxIdentity | undefined): Promise<void> { if (!identity) return; if ( typeof process.getuid !== "function" || typeof process.geteuid !== "function" || typeof process.setuid !== "function" || typeof process.setgid !== "function" || typeof process.setgroups !== "function" ) { throw new Error("The eval filesystem sandbox requires POSIX user APIs."); } if (process.getuid() !== 0 || process.geteuid() !== 0) { throw new Error("The eval runner must start as root before entering the unprivileged tool sandbox."); } const protectedPaths = await protectTransformedModules(); await chownTree(root, identity); process.setgroups([]); process.setgid(identity.gid); process.setuid(identity.uid); if (process.getuid() !== identity.uid || process.geteuid() !== identity.uid) { throw new Error("Failed to enter the unprivileged eval tool sandbox."); } for (const path of protectedPaths) { try { await readFile(path); } catch (error) { if (error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "ENOENT")) { continue; } throw error; } throw new Error(`The unprivileged eval process can read a transformed eval module: ${path}`); }}--read-only, /tmp là tmpfs, credential được mount chỉ đọc, UID sandbox là 65532 (docker.ts:91–124).README nói rõ giới hạn: tool của eval tài liệu chỉ gồm read, write, edit, grep, find, ls, nhưng container vẫn cần mạng để gọi provider, nên Docker một mình không chứng minh được code do agent viết không dùng mạng (README.md:90).
Kế hoạch, chạy, ghép cặp
src/cli.ts điều phối một lần so sánh: build hai image (id phải khác nhau), phát hiện ca trong cả hai biến thể và đòi hai tập ca giống hệt, ghi protocol.json kèm digest, chạy từng nhiệm vụ trong một container mới (tuần tự), ghi observations.jsonl sau mỗi nhiệm vụ, rồi tóm tắt; còn cặp bị chặn thì thoát mã khác 0 (cli.ts:114–192). Kế hoạch được lập trước khi chạy bất cứ thứ gì:
export function createTaskPlan( cases: readonly DiscoveredEvalCase[], model: string, runsPerVariant: number,): EvalTask[] { if (!model.includes("/") || model.startsWith("/") || model.endsWith("/")) { throw new TypeError("Model identity must contain a provider and model."); } if (!Number.isSafeInteger(runsPerVariant) || runsPerVariant < 1) { throw new TypeError("Runs per variant must be a positive integer."); } const tasks: EvalTask[] = []; for (const evalCase of cases) { for (let runNumber = 1; runNumber <= runsPerVariant; runNumber += 1) { const variants: DocumentationVariant[] = runNumber % 2 === 1 ? ["without_docs", "with_docs"] : ["with_docs", "without_docs"]; for (const variant of variants) tasks.push({ ...evalCase, variant, model, runNumber }); } } return tasks;}without_docs/with_docs giữa các lần lặp là để giảm thiên lệch do thứ tự (README.md:80). Mặc định một lần mỗi biến thể; tăng bằng --runs-per-variant khi cần đo độ ổn định (README.md:57–63).Báo cáo ghép từng cặp (ca, mô hình, lần chạy). Một cặp chỉ được tính khi mỗi nhánh có đúng một quan sát có điểm; thiếu, trùng, bị bỏ qua, chưa chạy, không chấm hay lỗi đều làm cặp bị chặn, kèm lý do (report.ts:249–281). "Đạt" nghĩa là điểm ≥ 1. Tỉ lệ đạt của từng bộ và độ nâng (treatment − control) chỉ được công bố khi bộ đó không có cặp nào bị chặn; token, lời gọi tool, thời gian và chi phí được so trung bình theo cặp, và telemetry vắng mặt được giữ là "không có" chứ không coi là 0 (report.ts:359–413; README.md:103–105). Báo cáo còn gắn cờ no-lift, negative-delta, control-saturated, treatment-saturated và flaky (cùng ca, cùng biến thể mà lúc đạt lúc không) (report.ts:336–357). "Telemetry" trong ngữ cảnh này là số liệu usage do harness gom, không liên quan tới pi-telemetry.
Quyết định thiết kế#
Một chuẩn đối chiếu duy nhất, và test để giữ nó
Mục tiêu của pi-env không phải "một môi trường từ xa hợp lý" mà là "giống hệt NodeExecutionEnv chạy ở đó". Đó là lý do có bảng chia việc: luật nằm ở client bằng TypeScript, system call ở daemon. Mục tiêu đó được kiểm bằng hai cách: bộ kiểm tuân thủ env của Durable chạy trên RemoteExecutionEnv qua một pipe và trên LocalExecutionEnv (test/conformance.test.ts:17–45), và một test vi sai dựng một cặp môi trường cục bộ và từ xa trên cùng máy rồi so các chuỗi thao tác ngẫu nhiên và chính các tool của Durable (test/differential.test.ts:24–33; README.md:63–66). Không có test như vậy, mọi khác biệt nhỏ (thứ tự readdir, một mã lỗi) sẽ lộ ra như một tool hành xử khác khi đổi máy.
SSH làm xác thực, daemon không mở cổng
Daemon không lắng nghe trên mạng; nó chỉ nói chuyện qua stdin/stdout của một phiên SSH. Xác thực, mã hoá và quyền là việc của SSH, và pi-env siết SSH tới mức tối thiểu: không chuyển tiếp gì, không dùng chung kết nối, không lệnh từ cấu hình, host key nghiêm theo file và alias riêng, key đổi không bao giờ được tự chấp nhận. Binary được đặt tên theo nội dung và được kiểm hash trước mỗi lần khởi động, nên một binary bị sửa trên máy từ xa không được chạy, và hai phiên bản không bao giờ đè lên nhau (README.md:20–21).
Không mồ côi, đổi lại không tiếp tục
Daemon giết mọi thứ khi kết nối im 30 giây hay stdin kết thúc. Lựa chọn ngược lại (để lệnh chạy tiếp và cho client nối lại) sẽ cần id phiên, đệm output và một giao thức tiếp tục; đổi lại, nó để lại tiến trình không ai quản khi một điện thoại mất mạng. pi-env chọn đơn giản và an toàn: lệnh chết theo kết nối, request đang bay thất bại với lost: true, và với thao tác có hiệu ứng, kết quả là "không biết". Đây cũng là ngữ nghĩa mà Durable đã có cho tool bị cắt ngang, nên hai tầng khớp nhau.
Câu trả lời nhỏ không chờ dữ liệu lớn
Một hàng control và một hàng bulk ở daemon, cộng cửa sổ output, giải hai vấn đề của link chậm: ping và kết quả của một lệnh huỷ không bị kẹt sau megabyte output, và output mà người gọi sẽ vứt đi thì không bao giờ được gửi. Cả hai dựa vào thông tin chỉ người gọi biết (nó giữ phần đuôi bao nhiêu, commit nhanh cỡ nào), nên Durable mới thêm ShellOutputWindow vào giao diện ExecutionEnv.
Telemetry: callback, tham số tường minh, không chứa nội dung
Span dựa trên callback (không có end()) nên không thể quên đóng span hay đóng hai lần, và status tự suy ra từ kết quả của callback. Context đi theo tham số chứ không theo AsyncLocalStorage, nên package chạy được trong Node, Bun, trình duyệt và worker (README.md:519), và hai lời gọi đồng thời không bao giờ lẫn cha. Đầu vào được tin để đường ghi không tốn công kiểm; kiểm nằm ở kiểu, qua schema. Và các schema của Pi cố ý chỉ chứa ID và metadata điều khiển, để bật telemetry không bao giờ có nghĩa là gửi prompt hay output của tool cho một bên thứ ba.
Eval: thà không có con số còn hơn một con số sai
Ghép cặp chính xác theo (ca, mô hình, lần chạy), lập kế hoạch toàn bộ trước khi chạy, và giữ lại tỉ lệ đạt khi có bất kỳ cặp nào bị chặn: thiết kế này chấp nhận mất kết quả của cả một lần chạy dài thay vì công bố một độ nâng tính trên một tập con không đầy đủ. Cô lập bằng quyền file (mã evaluator chỉ root đọc được, harness hạ quyền trước khi gọi mô hình) để mô hình không thể đọc bài chấm.
Cạm bẫy và trường hợp biên#
- Mất kết nối giữa một thao tác ghi. Request đang bay thất bại với
{ code: "unknown", lost: true }; nó có thể đã chạy xong ở xa.isConnectionLost(error)phân biệt trường hợp này với một lỗi daemon trả về (connection.ts:125–128). - Handle không sống qua lần khởi động lại. Reader đang mở khi kết nối mất sẽ thất bại ở lần đọc kế, kể cả khi kết nối đã có lại; mở lại reader.
- Laptop ngủ là lệnh từ xa chết. 30 giây không có byte nào là daemon giết process group. Build dài trên máy từ xa không sống qua lúc client ngủ.
- Lần khởi động nào cũng tốn thêm vòng SSH. Phát hiện hệ thống chỉ một lần cho mỗi
sshConnection, nhưng kiểm hash daemon chạy lại ở mỗi lần khởi động, trước request đầu tiên sau một lần mất kết nối. - Môi trường đăng nhập. Mặc định daemon không chạy qua shell đăng nhập, nên lệnh không thấy những gì
~/.profileđặt; bậtloginShell: truenếu cần (ssh.ts:463–472). Locale cũng không được chuyển tiếp. - Daemon cũ.
listFilestrảnot_supportedvới daemon trước 1.2.0; người gọi phải có đường duyệt cây của mình. - Windows và Termux. Terminal chưa có trên Windows; chuỗi lệnh trên Windows chạy qua Git Bash như
NodeExecutionEnv, argv chạy thẳng (README.md:58–59). Termux cầnTMPDIR,termux-execvàtermux-wake-lock. - Known-hosts riêng.
~/.ssh/known_hostsvà file toàn cục không được đọc; key phải được chấp nhận vào file của ứng dụng, dưới alias của môi trường. - Telemetry không phải nơi chứa dữ liệu. Đừng lưu
TelemetryContexthay span vào bản ghi; chỉ carrier được phép, và chỉ như dữ liệu tương quan tuỳ chọn. Adapter trong bộ nhớ không có trần; metric chỉ nên có thuộc tính ít giá trị (README.md:513–517). - Adapter tự viết phải mang năng lực đi theo. Từ 1.4.0,
metricsvàpropagationphải có trên gốc, trên mọi span con và trên context đã extract, kể cả span trơ; bộ conformance kiểm điều đó (CHANGELOG.md:9). - Eval tốn tiền và lộ nội dung. Mỗi nhánh gọi mô hình thật; artifact chứa prompt, câu trả lời, code sinh ra và output của tool (README.md:107). Một lần lặp không chứng minh được độ ổn định. Eval tài liệu từ chối chạy ngoài container sandbox (harness.ts:526–528).
Tài liệu lệch code
- README của pi-env nói daemon được "named by its SHA-256"; tên file thật dùng 32 ký tự hex đầu (128 bit) của hash (ssh.ts:380), còn việc kiểm trước khi chạy so cả hash đầy đủ.
- RFC 0019 ghi endpoint
pi.dev/install?version=…; coding agent ở v1.4.0 gọi/api/report-install(xem mục trên).
Đọc code ở đâu#
Thứ tự đọc cho từng package; đường dẫn tính từ gốc repo.
| # | File | Đọc để hiểu |
|---|---|---|
| 1 | env/README.md, docs/protocol.md, docs/semantics.md | Hai nửa của pi-env, giao thức frame, luật khớp với NodeExecutionEnv |
| 2 | durable/src/env/index.ts | Giao diện ExecutionEnv, ShellOutputWindow, ShellOutputInfo.skipped |
| 3 | env/src/connection.ts | Khởi động lười, dòng đồng bộ, frame, session, ping, lost |
| 4 | env/src/ssh.ts | Tham số SSH, host key, phát hiện hệ thống, triển khai daemon |
| 5 | env/src/remote-env.ts, errors.ts | Hiện thực ExecutionEnv: đường dẫn, đọc/ghi theo khúc, exec, cleanup |
| 6 | watch.ts, list-files.ts, terminal.ts | Watcher nối lại, listFiles, LocalExecutionEnv, terminal |
| 7 | daemon/src/main.rs, frame.rs, output.rs | Vòng frame, phân luồng, control trước bulk, liveness |
| 8 | exec.rs, window.rs, sys/unix.rs | Chạy lệnh, cửa sổ output, process group |
| 9 | watch.rs, list.rs, pty.rs | Watcher cạnh file, liệt kê theo gitignore, pseudo-terminal |
| 10 | test/conformance.test.ts, test/differential.test.ts | Cách lời hứa "giống NodeExecutionEnv" được kiểm |
| 11 | telemetry/README.md | Khái niệm, hợp đồng adapter, propagation, metric, store, schema, bảo mật |
| 12 | telemetry/src/index.ts, metrics.ts | Mọi kiểu của hợp đồng, schema và starter có kiểu |
| 13 | noop.ts, memory.ts, render.ts | Adapter no-op, adapter tham chiếu, in trace |
| 14 | testing/conformance.ts | Hợp đồng adapter ở dạng chạy được |
| 15 | durable/src/harness/telemetry.ts, submissions.ts | Schema của Durable; span submit; #phase trong scheduler.ts |
| 16 | evals/README.md | Hai loại eval, cách chạy, biến thể tài liệu, đọc kết quả |
| 17 | evals/src/harness.ts | Một coding agent cô lập cho mỗi lần chạy, sandbox, biến thể prompt |
| 18 | cli.ts, plan.ts, docker.ts, report.ts | Điều phối, kế hoạch, container, ghép cặp và độ nâng |
| 19 | docker/Dockerfile, entrypoint.ts, evals/ | Hai image, kiểm image, các bộ eval |
Tóm tắt#
- pi-env cho Pi Durable một
ExecutionEnvtrên máy khác: một daemon Rust nói giao thức frame qua stdin/stdout của SSH, còn worker, storage và credential ở lại máy cục bộ. - Client áp mọi luật của Node (đường dẫn, abort, mã lỗi, cách chia lần đọc), daemon làm system call; kết quả khớp
NodeExecutionEnvtrên máy đó, được giữ bằng conformance và test vi sai. - SSH bị siết: không chuyển tiếp, không chia sẻ kết nối, host key nghiêm theo file và alias riêng; daemon đặt tên theo hash và được kiểm trước mỗi lần khởi động.
- Frame có JSON và payload thô; trả lời đi trước output số lượng lớn; cửa sổ output bỏ phần người gọi sẽ vứt và báo lại bằng số đếm; 30 giây im lặng là daemon giết mọi thứ.
- pi-telemetry là hợp đồng span dựa trên callback, context tường minh, no-op và adapter trong bộ nhớ, propagation bằng carrier, link, metric, store và bộ conformance; schema có kiểu chỉ kiểm lúc biên dịch.
- Durable phát span
pi.durable.submitvàpi.durable.phasecùng ba metric, không chứa nội dung; carrier lưu trong bản ghi task giữ trace qua các lần khởi động lại. - pi-evals đo coding agent: host eval chạy tại chỗ, eval tài liệu so
with_docsvớiwithout_docstrong container cô lập, ghép cặp chính xác và giữ lại con số khi có cặp bị chặn.
21So sánh và lộ trình học#
Tới đây bạn đã thấy hai cách Pi ghi lại và chạy một cuộc hội thoại với mô hình: coding agent (session JSONL dạng cây, vòng lặp trong bộ nhớ) và Pi Durable (storage với dòng commit nguyên tử, mọi việc là task có checkpoint). Chương này đặt chúng cạnh nhau, vì so sánh là cách nhanh nhất để thấy mỗi lựa chọn thiết kế giải quyết vấn đề gì và phải trả giá gì. Cuối chương là lộ trình đọc code và các bài tập lần theo code.
Hai cách ghi lại một cuộc hội thoại#
Coding agent: một file, một cái cây
Mỗi session là một file JSONL: dòng đầu là header, các dòng sau là entry. Mọi entry có id và parentId, nên cả file là một cái cây; “nhánh đang hoạt động” là đường đi từ gốc tới lá.
Pi Durable: một storage, nhiều loại bản ghi
Một storage (bộ nhớ, SQLite, JSONL, IndexedDB hay Durable Object) chứa nhiều cuộc hội thoại, cùng với submission, task và document. Mọi thay đổi đi qua một dòng commit nguyên tử duy nhất.
export interface SessionEntryBase { type: string; id: string; parentId: string | null; timestamp: string;}/** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */export type SessionEntry = | SessionMessageEntry | ThinkingLevelChangeEntry | ModelChangeEntry | UsageEntry | CompactionEntry | BranchSummaryEntry | CustomEntry | CustomMessageEntry | ContextEditEntry | LabelEntry | SessionInfoEntry;/** Immutable identity, history ancestry, and task ownership of a transcript scope. */export type ConversationRecord = { readonly id: ConversationId; /** Fork source and inclusive parent entry through which history is inherited. */ readonly parent?: { readonly conversationId: ConversationId; readonly at: EntryId; }; /** Creator edge used for attribution, subtree abort, and subtree idle waits. */ readonly owner?: { readonly conversationId: ConversationId; readonly taskId: TaskId; };};/** Immutable transcript event with separate model-facing and application-facing payloads. */export type EntryRecord = { readonly id: EntryId; readonly conversationId: ConversationId; /** Application-defined entry discriminator. */ readonly kind: string; /** Messages contributed to model context; absent for display or bookkeeping entries. */ readonly model?: readonly Message[]; /** JSON payload consumed by views, extensions, or bookkeeping logic. */ readonly data?: JsonValue; /** First entry in the active context selected by this entry. */ readonly head?: EntryId; /** Context-only overrides of earlier visible entries. */ readonly edits?: readonly ContextEdit[]; /** Task that appended this entry, when it was produced by durable work. */ readonly byTaskId?: TaskId;};parent trỏ vào cuộc hội thoại gốc tại một entry; lịch sử không bị sao chép. Xem chương Pi Durable.Điểm khác dễ thấy nhất là mỗi entry của Durable mang hai phần tách bạch: model là các tin nhắn đóng góp vào ngữ cảnh mô hình, data là JSON tuỳ ý dành cho giao diện và extension. Coding agent giải quyết cùng nhu cầu bằng hai loại entry khác nhau: custom (dữ liệu của extension, không vào ngữ cảnh) và custom_message (vào ngữ cảnh, có details không gửi cho mô hình).
Một điểm giống nhau đáng chú ý: cả hai đều chỉ nối thêm, kể cả khi muốn “sửa” lịch sử. Coding agent có entry context_edit trỏ vào một entry cũ và thay hoặc bỏ phần nó đóng góp vào ngữ cảnh; Durable có trường edits với hành động omit hoặc replace. Bản gốc luôn còn đó; chỉ phép chiếu ra ngữ cảnh mô hình là thay đổi.
Bảng đối chiếu#
| Khía cạnh | Coding agent | Pi Durable |
|---|---|---|
| Đơn vị lưu trữ | Một file JSONL cho mỗi session | Một storage chứa mọi hội thoại, task, submission, document |
| Cách ghi | Nối một dòng cho mỗi entry, không có giao dịch | Commit nguyên tử gom entry, document, task, submission; được đánh số tuần tự |
| Định danh | Chuỗi 8 ký tự ngẫu nhiên lấy từ UUID | Số nguyên có “nhãn kiểu” (EntryId, TaskId…), tăng dần |
| Rẽ nhánh | Trong cùng file, qua parentId; fork tạo file mới có parentSession | Fork là hội thoại mới với parent: {conversationId, at} |
| Ngữ cảnh mô hình | Đường từ gốc tới lá; compaction mới nhất cắt tại firstKeptEntryId; áp dụng context_edit | Từ “head marker” mới nhất (reset hoặc compaction) tới entry cuối; áp dụng edits |
| Trạng thái ngoài transcript | Entry custom của extension; file cài đặt | Document có kiểu, phạm vi session/hội thoại/task, chính sách lịch sử và fork |
| Công việc đang chạy | Trong bộ nhớ của Agent; mất khi tiến trình chết | Bản ghi task với checkpoint; chạy tiếp sau khi mở lại |
| Steer và follow-up | Hàng đợi trong bộ nhớ của Agent | Document pi.inbox và trạng thái của SubmissionRecord |
| Người xem | Một giao diện (TUI, RPC hoặc SDK) trong cùng tiến trình | Bất kỳ số client nào qua viewState(), watch(), watchEvents() |
| Compaction | Chạy khi tới ngưỡng, chặn lượt tiếp theo; ghi compaction entry | Task nền bắt đầu trước ngưỡng; đặt bản tóm tắt ở ranh giới lượt kế tiếp |
| Trạng thái phát hành | Ổn định (1.x) | Thử nghiệm, API có thể đổi giữa các bản |
Cây trong một file: lá là dòng cuối#
Thiết kế cây của coding agent đáng học vì nó rất rẻ. Thêm một entry chỉ là nối một dòng; entry mới luôn là con của lá hiện tại, rồi trở thành lá:
_persist(entry: SessionEntry): void { if (!this.persist || !this.sessionFile) return; if (!this.flushed) { if (!this._hasConversation()) return; const fd = openSync(this.sessionFile, "wx"); try { for (const e of this.fileEntries) { writeFileSync(fd, `${JSON.stringify(e)}\n`); } } finally { closeSync(fd); } this.flushed = true; } else { appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); }}private _appendEntry(entry: SessionEntry): void { this.fileEntries.push(entry); this.byId.set(entry.id, entry); this.leafId = entry.id; this._persist(entry);}Rẽ nhánh còn rẻ hơn: branch() không ghi gì cả, nó chỉ dời con trỏ lá trong bộ nhớ. Entry tiếp theo sẽ có parentId trỏ vào điểm rẽ, và nhánh mới tự nhiên xuất hiện:
/** * Start a new branch from an earlier entry. * Moves the leaf pointer to the specified entry. The next appendXXX() call * will create a child of that entry, forming a new branch. Existing entries * are not modified or deleted. */branch(branchFromId: string): void { if (!this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } this.leafId = branchFromId;}Khi mở lại file, Pi không cần lưu con trỏ lá riêng: nó lấy entry cuối cùng trong file làm lá. Vì entry mới luôn được nối vào cuối, entry cuối chính là nơi bạn dừng lại.
private _buildIndex(): void { this.byId.clear(); this.labelsById.clear(); this.labelTimestampsById.clear(); this.leafId = null; for (const entry of this.fileEntries) { if (entry.type === "session") continue; this.byId.set(entry.id, entry); this.leafId = entry.id;Hai cách dựng ngữ cảnh#
Pi Durable không có cây trong một hội thoại; transcript của mỗi hội thoại là một dãy entry tăng dần. Thay vào đó, một số entry là head marker: chúng có trường head chỉ ra entry đầu tiên của ngữ cảnh đang hoạt động. pi.reset bắt đầu lại từ đầu, pi.compaction giữ bản tóm tắt và “dẫn đầu” entry đầu tiên được giữ lại. Ngữ cảnh luôn là head marker mới nhất cộng đoạn từ head của nó tới entry cuối:
/** * Derive the active transcript and model context of one conversation within captured bounds. * * H = newest visible head marker; the range runs from `H.head` (or transcript start) through the tail. Per target, * the newest edit in the range wins. Context entries are H followed by the range's non-head entries. */export async function deriveContext( storage: Storage, conversationId: ConversationId, bounds: ContextBounds | undefined, context: Context,): Promise<ContextView> { if (bounds === undefined) return emptyView(); return deriveRange(bounds, await scanRange(storage, conversationId, rangeQuery(bounds), context)).view;}function emptyView(): ContextView { return { head: undefined, entries: [], contributions: [], messages: [] };}/** Derive the context view from the entries scanned within `bounds`. */function deriveRange(bounds: ContextBounds, entries: readonly EntryRecord[]): ContextRange { const edits = new Map<EntryId, ContextEdit>(); // Edits of every entry in the range count, including older head markers that `selectActive()` drops. for (const entry of entries) for (const edit of entry.edits ?? []) edits.set(edit.target, edit); const active = selectActive(bounds.head, entries); const contributions = active.map((entry) => contribute(entry, edits.get(entry.id))); const { settled, open } = settle([], contributions.flat()); const baseline = findBaseline(bounds.head, active); const messages = leadWithSystem([...settled, ...orderToolResults(open)], baseline); const view = { head: bounds.head, entries: active, contributions, messages }; return { bounds, entries, view, edited: new Set(edits.keys()), settled, open, baseline };}Cả hai đều phải xử lý chung một tình huống khó: lời gọi tool không có kết quả (tiến trình chết giữa chừng, hoặc người dùng huỷ). Coding agent xử lý ở tầng pi-ai, ngay trước khi gửi request: nó bỏ qua tin nhắn assistant kết thúc bằng lỗi hoặc bị huỷ, và chèn một kết quả tool giả báo lỗi cho mọi lời gọi tool mồ côi, vì API của provider bắt buộc mỗi tool call phải có kết quả:
// Second pass: insert synthetic empty tool results for orphaned tool calls// This preserves thinking signatures and satisfies API requirementsconst result: Message[] = [];let pendingToolCalls: ToolCall[] = [];let existingToolResultIds = new Set<string>();// System messages are transparent to tool-call accounting: one that lands between a tool// call and its results is held back and emitted after the results (synthetic ones// included), so it never causes a duplicate result for a call that is answered later.const heldSystemMessages: Message[] = [];const closePendingToolCalls = () => { if (pendingToolCalls.length > 0) { for (const tc of pendingToolCalls) { if (!existingToolResultIds.has(tc.id)) { result.push({ role: "toolResult", toolCallId: tc.id, toolName: tc.name, content: [{ type: "text", text: "No result provided" }], isError: true, timestamp: Date.now(), } as ToolResultMessage); } } pendingToolCalls = []; existingToolResultIds = new Set(); } result.push(...heldSystemMessages); heldSystemMessages.length = 0;}; // Skip errored/aborted assistant messages entirely. // These are incomplete turns that shouldn't be replayed: // - May have partial content (reasoning without message, incomplete tool calls) // - Replaying them can cause API errors (e.g., OpenAI "reasoning without following item") // - The model should retry from the last valid state const assistantMsg = msg as AssistantMessage; if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") { continue; }Durable chọn cách khác, sớm hơn: ý định gọi tool được commit trước khi tool chạy, nên sau sự cố harness biết chính xác tool nào đang chạy dở. Tool khai báo replay: "safe" thì được chạy lại; tool khác nhận kết quả lỗi interrupted kèm phần output đã kịp commit (chương Durable: task). Phần dựng ngữ cảnh vẫn có lưới an toàn tương tự pi-ai cho lịch sử bị cắt ngang:
const EXCLUDED_STOP_REASONS: ReadonlySet<AssistantMessage["stopReason"]> = new Set(["aborted", "error", "deferred"]);const MISSING_RESULT_TEXT = "Tool result unavailable: history ends before this call completed.";Hai mô hình thực thi#
Coding agent
- Người dùng gửi prompt;
AgentSessionchuẩn bị ngữ cảnh rồi gọiAgent.prompt(). - Vòng lặp trong
pi-agent-corestream câu trả lời, chạy tool, phát sự kiện. - Mỗi tin nhắn hoàn tất được nối vào file session.
- Tiến trình chết: phần đang stream và tool đang chạy mất; lần sau mở session, bạn bảo Pi làm tiếp.
Pi Durable
submit()commit một submission (có thể kèmrequestIdđể không bao giờ gửi trùng).- Task
pi.generationgọi mô hình, commit từng phần câu trả lời vào documentpi.live, rồi commit entrypi.assistant. - Mỗi tool call là một task
pi.tooldo generation sở hữu; generation chờ chúng rồi trao lượt cho generation kế tiếp. - Tiến trình chết: mở lại storage,
resume()chạy tiếp từ checkpoint cuối cùng.
Lộ trình đọc code#
Nếu chỉ có thời gian đọc mười lăm file, hãy đọc theo thứ tự này. Mỗi file có chương tương ứng để đọc kèm.
| # | File | Đọc để hiểu | Chương |
|---|---|---|---|
| 1 | ai/src/types.ts | Tin nhắn, nội dung, mô hình, sự kiện stream: “ngôn ngữ chung” của mọi tầng | pi-ai |
| 2 | ai/src/api/anthropic-messages.ts | Một adapter provider hoàn chỉnh: dựng request, đọc stream, tính usage | pi-ai |
| 3 | ai/src/api/transform-messages.ts | Chuyển lịch sử giữa các provider, vá tool call mồ côi | pi-ai |
| 4 | agent/src/agent-loop.ts | Vòng lặp agent: lượt, tool, steer, follow-up | agent-core |
| 5 | coding-agent/src/core/agent-session.ts | Một prompt đi qua coding agent: extension, lưu session, retry, compaction | AgentSession |
| 6 | coding-agent/src/core/session-manager.ts | Định dạng session, cây, dựng ngữ cảnh từ nhánh | Session |
| 7 | coding-agent/src/core/system-prompt.ts và tools/edit.ts | System prompt và một tool điển hình | Công cụ |
| 8 | coding-agent/src/core/compaction/compaction.ts | Khi nào và cắt ở đâu để nén ngữ cảnh | Compaction |
| 9 | coding-agent/src/core/extensions/types.ts | Toàn bộ bề mặt API của extension và danh mục sự kiện | Extensions |
| 10 | tui/src/tui.ts | Vẽ lại theo vi sai | pi-tui |
| 11 | extensions/codemode/tool.ts | Tool codemode và cách nó gọi các tool khác | Codemode |
| 12 | chord/src/state.ts | State quan sát được, nền của mọi “view” trong Durable | Chord |
| 13 | durable/src/types.ts | Mọi bản ghi bền: hội thoại, entry, submission, task, document | Pi Durable |
| 14 | durable/src/session/transaction.ts | Một commit chứa gì và vì sao nó nguyên tử | Durable storage |
| 15 | durable/src/harness/scheduler.ts và harness/tool.ts | Task, checkpoint, phục hồi sau sự cố, replay của tool | Durable tasks |
Bài tập lần theo code#
Mỗi bài là một câu hỏi bạn trả lời bằng cách đọc code, kèm gợi ý nơi bắt đầu. Không cần chạy gì; nếu muốn chạy thử, hãy làm trong một thư mục tạm hoặc container, vì Pi chạy với quyền của bạn.
- Một lần bấm Enter. Từ lúc bạn gõ một câu trong editor tới khi token đầu tiên hiện ra, những hàm nào được gọi, theo thứ tự nào? Gợi ý: bắt đầu từ cách chế độ interactive gọi
AgentSession.prompt()(chương Các chế độ chạy), rồi theoAgent.prompt()vàorunLoopvàstreamAssistantResponse. - Đọc một session có hai nhánh. Mở một file trong thư mục session của bạn (vị trí ở chương Session), tìm hai entry có cùng
parentId. Dự đoán ngữ cảnh mà mô hình nhận được khi lá nằm ở nhánh thứ nhất, rồi đối chiếu với hàm dựng ngữ cảnh trongsession-manager.ts. - Chặn lệnh nguy hiểm. Ví dụ permission-gate.ts chặn
rm -rfbằng sự kiệntool_call. Tìm nơi runner gọi các handler của sự kiện này và giải thích vì sao trả về{ block: true }làm mô hình nhận một kết quả lỗi thay vì tool bị bỏ qua lặng lẽ. Gợi ý: runner.ts:1249 dừng ở handler đầu tiên chặn,AgentSessionnối nó vào hookbeforeToolCallcủa agent (agent-session.ts:653), và vòng lặp agent đổi lệnh chặn thành kết quả lỗi (agent-loop.ts:755). - Vì sao prompt cache vẫn trúng sau khi thêm tool. Khi một extension bật thêm tool giữa hội thoại, request kế tiếp có làm thay đổi phần đầu của prompt không? Gợi ý:
declareToolChangestrongagent-loop.tsvà chương Pi 1.0. - Compaction ở hai nơi. So sánh điều kiện kích hoạt compaction của coding agent với
settings.compactioncủa Durable (reserveTokens,keepRecentTokens,backgroundTokens). Bên nào bắt người dùng chờ, bên nào không, và vì sao? - Giết tiến trình giữa chừng. Trong Durable, nếu tiến trình chết lúc tool
bashđang chạynpm test, sau khi mở lại thì mô hình thấy gì? Còn nếu tool được khai báoreplay: "safe"? Gợi ý: ví dụtest/examples/13-recovery.tscủa package durable và chương Durable: task. Muốn chạy ví dụ, cài dependency ở gốc repo bằngnpm install --ignore-scriptsrồi làm theo hướng dẫn trong README của package. - Một fork không sao chép gì. Trong Durable, khi fork một hội thoại tại entry
e, các truy vấn entry của hội thoại mới thấy những entry nào, và bằng cơ chế nào mà không cần sao chép? Gợi ý:src/session/forks.tsvà chương Durable storage.
Tóm tắt#
- Coding agent: một file JSONL dạng cây, chỉ nối thêm; lá là dòng cuối; rẽ nhánh chỉ là dời con trỏ; công việc đang dở sống trong bộ nhớ và lịch sử được “vá” khi gửi đi.
- Pi Durable: một storage với dòng commit nguyên tử; entry tách
modelkhỏidata; head marker chọn ngữ cảnh; mọi việc là task có checkpoint nên chạy tiếp được sau sự cố. - Cả hai cùng tôn trọng một nguyên tắc: không bao giờ xoá hay sửa lịch sử; mọi “chỉnh sửa” là một bản ghi mới thay đổi phép chiếu ra ngữ cảnh mô hình.
22Thuật ngữ và nguồn tham khảo#
Bảng dưới gom các thuật ngữ dùng trong tài liệu, cách gọi tiếng Việt được dùng thống nhất, nghĩa ngắn gọn, và nơi chúng được định nghĩa trong code. Khi gặp một từ lạ ở bất kỳ chương nào, hãy quay lại đây rồi bấm vào định nghĩa.
Mô hình và vòng lặp#
| Thuật ngữ | Trong tài liệu | Nghĩa | Định nghĩa |
|---|---|---|---|
| harness | harness | Phần mềm bao quanh mô hình: system prompt, tool, vòng lặp, lớp phiên dịch. | Lịch sử và triết lý |
| API, provider | API (giao thức), provider (nhà cung cấp) | API là giao thức dây (Anthropic Messages, OpenAI Responses…); provider là bên cung cấp (anthropic, openai, openrouter…). Một provider có thể dùng API của provider khác. Bản đồ đầy đủ ở chương Provider. | Model |
| context | ngữ cảnh (của một request) | System prompt, danh sách tin nhắn và tool gửi cho mô hình trong một lần gọi. | Context |
| message | tin nhắn | Một trong bốn vai: system, user, assistant, toolResult. | Message |
| tool call | lời gọi tool | Khối nội dung trong tin nhắn assistant: tên tool, id, đối số. | ToolCall |
| usage | usage | Số token vào/ra, đọc/ghi cache và chi phí của một phản hồi. | Usage |
| stop reason | lý do dừng | stop, length, toolUse, error, aborted, deferred… | StopReason |
| assistant message event | sự kiện stream | Các sự kiện bắt đầu, delta văn bản, delta suy nghĩ, delta tool call, kết thúc, lỗi. | AssistantMessageEvent |
| thinking level | mức suy nghĩ | Mức lý luận yêu cầu mô hình dùng, ánh xạ sang tham số riêng của từng provider. | ThinkingLevel |
| agent loop | vòng lặp agent | Gọi mô hình, chạy các tool nó gọi, lặp lại tới khi nó không gọi tool nữa. | runLoop |
| turn, run | lượt, lần chạy | Lượt: một phản hồi của mô hình cùng các tool call của nó. Lần chạy: các lượt từ một input tới câu trả lời cuối. | AgentEvent |
| agent message | tin nhắn agent | Tin nhắn của LLM cộng các loại tin nhắn tuỳ biến mà ứng dụng khai báo thêm. | AgentMessage |
| steering, follow-up | tin nhắn steer, follow-up | Steer chen vào sau lượt đang chạy; follow-up chờ agent làm xong rồi mới vào. | Agent |
Coding agent#
| Thuật ngữ | Trong tài liệu | Nghĩa | Định nghĩa |
|---|---|---|---|
| AgentSession | AgentSession | Bộ điều phối của coding agent: nối Agent, session, cài đặt, mô hình, extension, tool. | AgentSession |
| session, entry | session, entry | File JSONL của một hội thoại; mỗi dòng sau header là một entry có id và parentId. | SessionEntry |
| branch, leaf | nhánh, lá | Lá là entry hiện tại; nhánh hoạt động là đường từ gốc tới lá. Rẽ nhánh là dời lá về một entry cũ. | branch() |
| compaction | compaction (nén ngữ cảnh) | Thay phần lịch sử cũ bằng một bản tóm tắt; bản gốc vẫn nằm trong file. | CompactionEntry |
| branch summary | tóm tắt nhánh | Bản tóm tắt của nhánh vừa rời đi, gắn vào chỗ rẽ nhánh mới. | BranchSummaryEntry |
| context edit | chỉnh sửa ngữ cảnh | Entry chỉ-nối-thêm thay hoặc bỏ phần đóng góp vào ngữ cảnh của một entry cũ. | ContextEditEntry |
| extension | extension | Module TypeScript chạy trong tiến trình Pi, đăng ký tool, lệnh, sự kiện, giao diện. | ExtensionAPI |
| skill | skill | Thư mục chỉ dẫn (SKILL.md) chỉ được nạp toàn văn khi cần; system prompt chỉ liệt kê tên và mô tả. | Skill |
| prompt template | prompt template | File Markdown được mở rộng thành tin nhắn khi gõ /tên. | PromptTemplate |
| project trust | project trust | Quyết định có nạp cài đặt và tài nguyên của thư mục dự án hay không; không phải sandbox. | security.md |
| component | component (TUI) | Đơn vị giao diện terminal: nhận độ rộng, trả về các dòng văn bản. | Component |
| codemode | codemode | Tool cho mô hình viết JavaScript chạy trong sandbox QuickJS để gọi các tool khác. | chương Codemode |
| tool exposure | mức hiển thị tool | Tool được khai báo thẳng cho mô hình, chỉ cho codemode, trì hoãn tới khi tìm, hay ẩn. | chương Codemode |
Chord và Pi Durable#
| Thuật ngữ | Trong tài liệu | Nghĩa | Định nghĩa |
|---|---|---|---|
| Context (Chord) | Context của Chord | Đối tượng mang tín hiệu huỷ và span telemetry, truyền qua mọi lời gọi bất đồng bộ của Durable. Khác với “context” của pi-ai. | Context, BACKGROUND_CONTEXT |
| Harness | Harness | Một storage đang mở cùng bộ máy chạy agent trên nó. | Harness |
| conversation | hội thoại (conversation) | Một transcript; có thể là fork của hội thoại khác hoặc do một task sở hữu. | ConversationRecord |
| entry (Durable) | entry | Bản ghi bất biến của transcript, tách model (cho mô hình) và data (cho ứng dụng). | EntryRecord |
| head marker | head marker | Entry có trường head (reset, compaction) chọn điểm bắt đầu của ngữ cảnh hoạt động. | ContextBounds |
| submission | submission | Một input hoặc một lần ghi entry được đưa vào hội thoại; có trạng thái queued, placed, done, unanswered. | SubmissionRecord |
| task, checkpoint | task, checkpoint | Máy trạng thái bền; mỗi bước lưu checkpoint để chạy tiếp sau khi khởi động lại. | TaskState, defineTask |
| document | document | State JSON có kiểu, lưu cạnh transcript và thay đổi trong cùng commit. | DocumentRecord, defineDoc |
| extension (Durable) | extension | Gói có tên gồm tool, section của system prompt, hook, wrapper và task. | defineExtension |
| replay | phát lại (replay) | Chạy lại một tool sau sự cố; chỉ với tool khai báo replay: "safe". | chương Durable: task |
| execution environment | môi trường thực thi | Nơi tool đọc ghi file và chạy tiến trình: máy cục bộ, máy từ xa qua pi-env, sandbox. | ExecutionEnv |
Nguồn tham khảo#
Mã nguồn và tài liệu chính thức
- github.com/earendil-works/pi: monorepo, MIT. Tài liệu này dùng tag
v1.4.0. - pi.dev và pi.dev/docs/latest: trang chủ và tài liệu người dùng (bản xuất bản của
packages/coding-agent/docs). - rfc.earendil.com: các RFC công khai về Pi (telemetry, bộ cài khoá dependency, cờ thử nghiệm, cấu hình mô hình động…).
- Tài liệu trong repo đáng đọc trọn: how-pi-works.md, session-format.md, compaction.md, extensions.md, durable/README.md, durable/docs/spec.md (đặc tả chuẩn của Pi Durable), chord/docs/guide.md.
Blog
- Mario Zechner, What I learned building an opinionated and minimal coding agent (2025-11-30).
- Armin Ronacher, Pi: The Minimal Agent Within OpenClaw (2026-01-31).
- Earendil, Announcing Pi & Lefos (2026-04-08).
- Earendil, Prompt Caching In Agents (2026-07-22).
- Earendil, The Session You Cannot Take With You (2026-07-30).
- Earendil, Pi, Minimal and Performant (2026-08-04).
- Earendil, How Compaction Works in Pi (2026-08-13).
- Earendil, What is a Harness? (2026-08-20).
- Earendil, There are many agent harnesses, but this one is mine. (2026-09-01).
- Earendil, You Said No MCP! (2026-09-29).
- Earendil, Pi 1.0 và Pi Durable (2026-10-01).
- Earendil, There are many themes, but this one is yours (2026-10-09).
- Earendil, Pi Durable on Cloudflare (2026-10-16), kèm repo mẫu earendil-works/pi-durable-cloudflare.
Dữ liệu và hệ sinh thái
- Các phiên làm việc thật của Mario khi phát triển Pi được công bố tại huggingface.co/datasets/badlogicgames/pi-mono (README.md:182). Đây là nguồn rất tốt để xem session JSONL thật trông thế nào.
- Trên npm đã có các package bên thứ ba xây trên Pi Durable, ví dụ backend PostgreSQL, môi trường thực thi trên Fly.io Sprites, host trên Cloudflare Durable Object hay subagent bền. Chúng là mã của cộng đồng, không thuộc repo và không được kiểm chứng trong tài liệu này.