diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts
index d02b4a5a5..18d64526e 100644
--- a/src/agents/pi-embedded-runner/compact.ts
+++ b/src/agents/pi-embedded-runner/compact.ts
@@ -79,6 +79,10 @@ export type CompactEmbeddedPiSessionParams = {
authProfileId?: string;
/** Optional override for compaction keep-recent tokens (manual /compact). */
compactionKeepRecentTokensOverride?: number;
+ /** Override compaction mode for this run (used for fallback attempts). */
+ compactionModeOverride?: "default" | "safeguard" | "handoff";
+ /** Allow a fallback compaction pass (handoff -> safeguard). */
+ compactionAllowFallback?: boolean;
/** Group id for channel-level tool policy resolution. */
groupId?: string | null;
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
@@ -367,12 +371,28 @@ export async function compactEmbeddedPiSessionDirect(
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
});
trackSessionManagerAccess(params.sessionFile);
+ const compactionCfg =
+ params.compactionModeOverride && params.config
+ ? {
+ ...params.config,
+ agents: {
+ ...params.config.agents,
+ defaults: {
+ ...params.config.agents?.defaults,
+ compaction: {
+ ...params.config.agents?.defaults?.compaction,
+ mode: params.compactionModeOverride,
+ },
+ },
+ },
+ }
+ : params.config;
const settingsManager = SettingsManager.create(effectiveWorkspace, agentDir);
ensurePiCompactionReserveTokens({
settingsManager,
- minReserveTokens: resolveCompactionReserveTokensFloor(params.config),
+ minReserveTokens: resolveCompactionReserveTokensFloor(compactionCfg),
});
- const isHandoffMode = params.config?.agents?.defaults?.compaction?.mode === "handoff";
+ const isHandoffMode = compactionCfg?.agents?.defaults?.compaction?.mode === "handoff";
if (typeof params.compactionKeepRecentTokensOverride === "number") {
settingsManager.applyOverrides({
compaction: {
@@ -381,7 +401,7 @@ export async function compactEmbeddedPiSessionDirect(
});
}
const additionalExtensionPaths = buildEmbeddedExtensionPaths({
- cfg: params.config,
+ cfg: compactionCfg,
sessionManager,
provider,
modelId,
@@ -443,7 +463,25 @@ export async function compactEmbeddedPiSessionDirect(
const customInstructions = isHandoffMode
? params.customInstructions?.trim()
: params.customInstructions;
- const result = await session.compact(customInstructions || undefined);
+ let result;
+ try {
+ result = await session.compact(customInstructions || undefined);
+ } catch (err) {
+ const allowFallback = params.compactionAllowFallback !== false;
+ if (isHandoffMode && allowFallback) {
+ log.warn(
+ `handoff compaction failed; retrying with safeguard: ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ );
+ return compactEmbeddedPiSessionDirect({
+ ...params,
+ compactionModeOverride: "safeguard",
+ compactionAllowFallback: false,
+ });
+ }
+ throw err;
+ }
// Estimate tokens after compaction by summing token estimates for remaining messages
let tokensAfter: number | undefined;
try {
diff --git a/src/agents/pi-extensions/compaction-handoff.ts b/src/agents/pi-extensions/compaction-handoff.ts
index 1f89868ca..074965692 100644
--- a/src/agents/pi-extensions/compaction-handoff.ts
+++ b/src/agents/pi-extensions/compaction-handoff.ts
@@ -2,14 +2,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ExtensionAPI, FileOperations } from "@mariozechner/pi-coding-agent";
import { completeSimple } from "@mariozechner/pi-ai";
-import {
- computeAdaptiveChunkRatio,
- estimateMessagesTokens,
- pruneHistoryForContextShare,
- resolveContextWindowTokens,
- summarizeInStages,
-} from "../compaction.js";
-
import { log } from "../pi-embedded-runner/logger.js";
type ModelSnapshotEntry = {
@@ -39,8 +31,6 @@ const SYSTEM_PROMPT =
const DEFAULT_INSTRUCTIONS =
"Summarize the conversation for continuity. Preserve exact file paths, commands, and errors." +
" Keep it concise.";
-const MAX_TOOL_FAILURES = 8;
-const MAX_TOOL_FAILURE_CHARS = 240;
function computeFileLists(fileOps: FileOperations): {
readFiles: string[];
@@ -52,110 +42,6 @@ function computeFileLists(fileOps: FileOperations): {
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")}`;
-}
-
-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 extractTextFromBlocks(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
@@ -306,79 +192,6 @@ function buildSplitTurnMessages(
return [...messagesToSummarize, marker, ...turnPrefixMessages];
}
-async function summarizeSafeguard(params: {
- preparation: CompactionPreparationLike;
- model: NonNullable;
- apiKey: string;
- signal: AbortSignal;
- customInstructions?: string;
-}): Promise {
- const { preparation, model, apiKey, signal, customInstructions } = params;
- const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps);
- const fileOpsSummary = formatFileOperations(readFiles, modifiedFiles);
- const toolFailures = collectToolFailures([
- ...preparation.messagesToSummarize,
- ...preparation.turnPrefixMessages,
- ]);
- const toolFailureSection = formatToolFailuresSection(toolFailures);
-
- const contextWindowTokens = resolveContextWindowTokens(model);
- const turnPrefixMessages = preparation.turnPrefixMessages ?? [];
- let messagesToSummarize = preparation.messagesToSummarize;
-
- const tokensBefore =
- typeof preparation.tokensBefore === "number" && Number.isFinite(preparation.tokensBefore)
- ? preparation.tokensBefore
- : undefined;
- if (tokensBefore !== undefined) {
- const summarizableTokens =
- estimateMessagesTokens(messagesToSummarize) + estimateMessagesTokens(turnPrefixMessages);
- const newContentTokens = Math.max(0, Math.floor(tokensBefore - summarizableTokens));
- const maxHistoryTokens = Math.floor(contextWindowTokens * 0.5);
-
- if (newContentTokens > maxHistoryTokens) {
- const pruned = pruneHistoryForContextShare({
- messages: messagesToSummarize,
- maxContextTokens: contextWindowTokens,
- maxHistoryShare: 0.5,
- 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;
- }
- }
- }
-
- const allMessages = buildSplitTurnMessages(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));
-
- const historySummary = await summarizeInStages({
- messages: allMessages,
- model,
- apiKey,
- signal,
- reserveTokens,
- maxChunkTokens,
- contextWindow: contextWindowTokens,
- customInstructions,
- previousSummary: preparation.previousSummary,
- });
-
- let summary = historySummary;
- summary += toolFailureSection;
- summary += fileOpsSummary;
- return summary;
-}
-
export default function compactionFreeformExtension(api: ExtensionAPI): void {
api.on("session_before_compact", async (event, ctx) => {
const { preparation, customInstructions, signal } = event;
@@ -454,36 +267,10 @@ export default function compactionFreeformExtension(api: ExtensionAPI): void {
};
} catch (error) {
console.warn(
- `Freeform compaction failed; trying safeguard: ${
+ `Handoff compaction failed; falling back to default compaction: ${
error instanceof Error ? error.message : String(error)
}`,
);
- }
-
- try {
- const safeSummary = await summarizeSafeguard({
- preparation,
- model,
- apiKey,
- signal,
- customInstructions,
- });
- log.info("compaction handoff: used safeguard fallback");
- return {
- compaction: {
- summary: safeSummary,
- firstKeptEntryId: preparation.firstKeptEntryId,
- tokensBefore: preparation.tokensBefore,
- details: { readFiles, modifiedFiles },
- },
- };
- } catch (error) {
- console.warn(
- `Safeguard compaction failed; falling back to default compaction: ${
- error instanceof Error ? error.message : String(error)
- }`,
- );
- log.info("compaction handoff: falling back to default");
return;
}
});