Chương 21Phần 6: Tổng hợp
So sánh và lộ trình học
Coding agent và Pi Durable đặt cạnh nhau, lộ trình đọc code và bài tập lần theo code.
Trong chương này8 mụ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.