Chương 10Phần 3: Coding agent
Compaction và tóm tắt nhánh
Khi nào nén ngữ cảnh, chọn điểm cắt, prompt tóm tắt và đánh đổi với prompt cache.
Trong chương này17 mục
- Vai trò
- Mô hình tư duy
- Data model
- Khi nào compaction chạy
- Ước lượng token
- Chọn điểm cắt
- Prompt tóm tắt và định dạng bản tóm tắt
- Theo dõi file đã đọc và đã sửa
- Ghi entry và dựng lại context
- Extension tuỳ biến hoặc thay compaction
- Tóm tắt nhánh khi rời nhánh qua /tree
- Đánh đổi với prompt cache
- Ví dụ có số liệu: một session vượt ngưỡng
- Quyết định thiết kế
- Cạm bẫy và trường hợp biên
- Đọc code ở đâu
- Tóm tắt
Vai trò#
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.