import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ExtensionAPI, FileOperations } from "@mariozechner/pi-coding-agent"; import { BASE_CHUNK_RATIO, MIN_CHUNK_RATIO, SAFETY_MARGIN, computeAdaptiveChunkRatio, estimateMessagesTokens, isOversizedForSummary, pruneHistoryForContextShare, resolveContextWindowTokens, summarizeInStages, } from "../compaction.js"; import { getCompactionSafeguardRuntime } from "./compaction-safeguard-runtime.js"; const FALLBACK_SUMMARY = "Summary unavailable due to context limits. Older messages were truncated."; const TURN_PREFIX_INSTRUCTIONS = "This summary covers the prefix of a split turn. Focus on the original request," + " early progress, and any details needed to understand the retained suffix."; const MAX_TOOL_FAILURES = 8; const MAX_TOOL_FAILURE_CHARS = 240; type ToolFailure = { toolCallId: string; toolName: string; summary: string; meta?: string; }; function normalizeFailureText(text: string): string { return text.replace(/\s+/g, " ").trim(); } function truncateFailureText(text: string, maxChars: number): string { if (text.length <= maxChars) return text; return `${text.slice(0, Math.max(0, maxChars - 3))}...`; } function formatToolFailureMeta(details: unknown): string | undefined { if (!details || typeof details !== "object") return undefined; const record = details as Record; const status = typeof record.status === "string" ? record.status : undefined; const exitCode = typeof record.exitCode === "number" && Number.isFinite(record.exitCode) ? record.exitCode : undefined; const parts: string[] = []; if (status) parts.push(`status=${status}`); if (exitCode !== undefined) parts.push(`exitCode=${exitCode}`); return parts.length > 0 ? parts.join(" ") : undefined; } function extractToolResultText(content: unknown): string { if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const block of content) { if (!block || typeof block !== "object") continue; const rec = block as { type?: unknown; text?: unknown }; if (rec.type === "text" && typeof rec.text === "string") { parts.push(rec.text); } } return parts.join("\n"); } function collectToolFailures(messages: AgentMessage[]): ToolFailure[] { const failures: ToolFailure[] = []; const seen = new Set(); for (const message of messages) { if (!message || typeof message !== "object") continue; const role = (message as { role?: unknown }).role; if (role !== "toolResult") continue; const toolResult = message as { toolCallId?: unknown; toolName?: unknown; content?: unknown; details?: unknown; isError?: unknown; }; if (toolResult.isError !== true) continue; const toolCallId = typeof toolResult.toolCallId === "string" ? toolResult.toolCallId : ""; if (!toolCallId || seen.has(toolCallId)) continue; seen.add(toolCallId); const toolName = typeof toolResult.toolName === "string" && toolResult.toolName.trim() ? toolResult.toolName : "tool"; const rawText = extractToolResultText(toolResult.content); const meta = formatToolFailureMeta(toolResult.details); const normalized = normalizeFailureText(rawText); const summary = truncateFailureText( normalized || (meta ? "failed" : "failed (no output)"), MAX_TOOL_FAILURE_CHARS, ); failures.push({ toolCallId, toolName, summary, meta }); } return failures; } function formatToolFailuresSection(failures: ToolFailure[]): string { if (failures.length === 0) return ""; const lines = failures.slice(0, MAX_TOOL_FAILURES).map((failure) => { const meta = failure.meta ? ` (${failure.meta})` : ""; return `- ${failure.toolName}${meta}: ${failure.summary}`; }); if (failures.length > MAX_TOOL_FAILURES) { lines.push(`- ...and ${failures.length - MAX_TOOL_FAILURES} more`); } return `\n\n## Tool Failures\n${lines.join("\n")}`; } function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[]; } { const modified = new Set([...fileOps.edited, ...fileOps.written]); const readFiles = [...fileOps.read].filter((f) => !modified.has(f)).sort(); const modifiedFiles = [...modified].sort(); return { readFiles, modifiedFiles }; } function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string { const sections: string[] = []; if (readFiles.length > 0) { sections.push(`\n${readFiles.join("\n")}\n`); } if (modifiedFiles.length > 0) { sections.push(`\n${modifiedFiles.join("\n")}\n`); } if (sections.length === 0) return ""; return `\n\n${sections.join("\n\n")}`; } export default function compactionSafeguardExtension(api: ExtensionAPI): void { api.on("session_before_compact", async (event, ctx) => { const { preparation, customInstructions, signal } = event; const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps); const fileOpsSummary = formatFileOperations(readFiles, modifiedFiles); const toolFailures = collectToolFailures([ ...preparation.messagesToSummarize, ...preparation.turnPrefixMessages, ]); const toolFailureSection = formatToolFailuresSection(toolFailures); const fallbackSummary = `${FALLBACK_SUMMARY}${toolFailureSection}${fileOpsSummary}`; const model = ctx.model; if (!model) { return { compaction: { summary: fallbackSummary, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, details: { readFiles, modifiedFiles }, }, }; } const apiKey = await ctx.modelRegistry.getApiKey(model); if (!apiKey) { return { compaction: { summary: fallbackSummary, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, details: { readFiles, modifiedFiles }, }, }; } try { const contextWindowTokens = resolveContextWindowTokens(model); const turnPrefixMessages = preparation.turnPrefixMessages ?? []; let messagesToSummarize = preparation.messagesToSummarize; const runtime = getCompactionSafeguardRuntime(ctx.sessionManager); const maxHistoryShare = runtime?.maxHistoryShare ?? 0.5; const tokensBefore = typeof preparation.tokensBefore === "number" && Number.isFinite(preparation.tokensBefore) ? preparation.tokensBefore : undefined; let droppedSummary: string | undefined; if (tokensBefore !== undefined) { const summarizableTokens = estimateMessagesTokens(messagesToSummarize) + estimateMessagesTokens(turnPrefixMessages); const newContentTokens = Math.max(0, Math.floor(tokensBefore - summarizableTokens)); // Apply SAFETY_MARGIN so token underestimates don't trigger unnecessary pruning const maxHistoryTokens = Math.floor(contextWindowTokens * maxHistoryShare * SAFETY_MARGIN); if (newContentTokens > maxHistoryTokens) { const pruned = pruneHistoryForContextShare({ messages: messagesToSummarize, maxContextTokens: contextWindowTokens, maxHistoryShare, parts: 2, }); if (pruned.droppedChunks > 0) { const newContentRatio = (newContentTokens / contextWindowTokens) * 100; console.warn( `Compaction safeguard: new content uses ${newContentRatio.toFixed( 1, )}% of context; dropped ${pruned.droppedChunks} older chunk(s) ` + `(${pruned.droppedMessages} messages) to fit history budget.`, ); messagesToSummarize = pruned.messages; // Summarize dropped messages so context isn't lost if (pruned.droppedMessagesList.length > 0) { try { const droppedChunkRatio = computeAdaptiveChunkRatio( pruned.droppedMessagesList, contextWindowTokens, ); const droppedMaxChunkTokens = Math.max( 1, Math.floor(contextWindowTokens * droppedChunkRatio), ); droppedSummary = await summarizeInStages({ messages: pruned.droppedMessagesList, model, apiKey, signal, reserveTokens: Math.max(1, Math.floor(preparation.settings.reserveTokens)), maxChunkTokens: droppedMaxChunkTokens, contextWindow: contextWindowTokens, customInstructions, previousSummary: preparation.previousSummary, }); } catch (droppedError) { console.warn( `Compaction safeguard: failed to summarize dropped messages, continuing without: ${ droppedError instanceof Error ? droppedError.message : String(droppedError) }`, ); } } } } } // Use adaptive chunk ratio based on message sizes const allMessages = [...messagesToSummarize, ...turnPrefixMessages]; const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens); const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio)); const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens)); // Feed dropped-messages summary as previousSummary so the main summarization // incorporates context from pruned messages instead of losing it entirely. const effectivePreviousSummary = droppedSummary ?? preparation.previousSummary; const historySummary = await summarizeInStages({ messages: messagesToSummarize, model, apiKey, signal, reserveTokens, maxChunkTokens, contextWindow: contextWindowTokens, customInstructions, previousSummary: effectivePreviousSummary, }); let summary = historySummary; if (preparation.isSplitTurn && turnPrefixMessages.length > 0) { const prefixSummary = await summarizeInStages({ messages: turnPrefixMessages, model, apiKey, signal, reserveTokens, maxChunkTokens, contextWindow: contextWindowTokens, customInstructions: TURN_PREFIX_INSTRUCTIONS, previousSummary: undefined, }); summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`; } summary += toolFailureSection; summary += fileOpsSummary; return { compaction: { summary, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, details: { readFiles, modifiedFiles }, }, }; } catch (error) { console.warn( `Compaction summarization failed; truncating history: ${ error instanceof Error ? error.message : String(error) }`, ); return { compaction: { summary: fallbackSummary, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, details: { readFiles, modifiedFiles }, }, }; } }); } export const __testing = { collectToolFailures, formatToolFailuresSection, computeAdaptiveChunkRatio, isOversizedForSummary, BASE_CHUNK_RATIO, MIN_CHUNK_RATIO, SAFETY_MARGIN, } as const;