/** Context maintenance for an active coding-agent session. */ import { scheduler } from "node:timers/promises"; import { type Agent, type AgentMessage, type AgentTurnEndContext, countTokens, resolveTelemetry, type StreamFn, type ThinkingLevel, } from "@oh-my-pi/pi-agent-core"; import { AGGRESSIVE_SHAKE_CONFIG, AUTO_HANDOFF_THRESHOLD_FOCUS, applyShakeRegions, CompactionCancelledError, type CompactionPreparation, type CompactionResult, type CompactionSettings, calculateContextTokens, collectShakeRegions, compact, compactionContextTokens, createCompactionSummaryMessage, DEFAULT_SHAKE_CONFIG, effectiveReserveTokens, estimateTokens, NativeCompactionError, prepareCompaction, resolveBudgetReserveTokens, resolveThresholdTokens, type ShakeConfig, type ShakeRegion, type SummaryOptions, shouldCompact, shouldUseOpenAiRemoteCompaction, shouldUseProviderNativeCompaction, } from "@oh-my-pi/pi-agent-core/compaction"; import { DEFAULT_PRUNE_CONFIG, pruneSupersededToolResults, pruneToolOutputs, readToolSupersedeKey, } from "@oh-my-pi/pi-agent-core/compaction/pruning"; import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/tool-protection"; import type { AssistantMessage, CodexCompactionContext, Message, Model, ProviderSessionState } from "@oh-my-pi/pi-ai"; import * as AIError from "@oh-my-pi/pi-ai/error"; import { preferredDialect } from "@oh-my-pi/pi-catalog/identity"; import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { logger } from "@oh-my-pi/pi-utils"; import * as snapcompact from "@oh-my-pi/snapcompact"; import type { ModelRegistry } from "../config/model-registry"; import { MODEL_ROLE_IDS } from "../config/model-roles"; import type { Settings } from "../config/settings"; import { getDefault } from "../config/settings"; import type { ExtensionRunner, SessionBeforeCompactResult } from "../extensibility/extensions"; import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types"; import type { GoalModeState } from "../goals/state"; import { resolveMemoryBackend } from "../memory-backend/resolve"; import type { MemoryBackendOperationContext } from "../memory-backend/types"; import type { NonMessageTokenSource } from "../modes/utils/context-usage"; import { computeNonMessageTokens } from "../modes/utils/context-usage"; import { createPlanReadMatcher } from "../plan-mode/plan-protection"; import type { ConfiguredThinkingLevel } from "../thinking"; import type { AgentSessionEvent } from "./agent-session-events"; import type { ContextUsageBreakdown, HandoffResult, SessionHandoffOptions } from "./agent-session-types"; import { findCompactMode } from "./compact-modes"; import { convertToLlm, stripImagesFromMessage } from "./messages"; import { isTerminalTextAssistantAnswer } from "./queued-messages"; import { resolveCompactionConfiguredTarget, resolveContextPromotionConfiguredTarget, resolveRoleModelFull, } from "./role-models"; import type { SessionContext } from "./session-context"; import { getLatestCompactionEntry } from "./session-context"; import type { CompactionEntry, SessionEntry } from "./session-entries"; import type { SessionManager } from "./session-manager"; import type { ShakeMode, ShakeResult } from "./shake-types"; export type CompactionCheckResult = Readonly<{ deferredHandoff: boolean; continuationScheduled: boolean; automaticContinuationBlocked?: boolean; historyRewritten?: boolean; }>; /** Shared no-op result for dispatcher paths that perform no maintenance. */ export const COMPACTION_CHECK_NONE: CompactionCheckResult = { deferredHandoff: false, continuationScheduled: false, }; const COMPACTION_CHECK_DEFERRED_HANDOFF: CompactionCheckResult = { deferredHandoff: true, continuationScheduled: false, }; const COMPACTION_CHECK_CONTINUATION: CompactionCheckResult = { deferredHandoff: false, continuationScheduled: true, }; const COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION: CompactionCheckResult = { deferredHandoff: false, continuationScheduled: false, automaticContinuationBlocked: true, }; /** * User-facing notice for a compaction dead end: maintenance freed too little * to retry safely. `remedies` names the recovery actions left on the emitting * path — by the time the post-pass dead end fires, the tiered rescue has * already attempted both elide and image-drop automatically. */ function compactionDeadEndWarning(remedies: string): string { return ( "Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. " + `The most recent turn alone is too large to reduce further; ${remedies} or switch to a larger-context model.` ); } /** Creates one provider-scoped compaction lifecycle descriptor. */ export function createCodexCompactionContext(options: { trigger: CodexCompactionContext["trigger"]; reason: CodexCompactionContext["reason"]; phase: CodexCompactionContext["phase"]; }): CodexCompactionContext { return { operationId: crypto.randomUUID(), trigger: options.trigger, reason: options.reason, phase: options.phase, strategy: "memento", }; } /** * Per-turn prune cache window. A tool result whose all-message suffix exceeds * this is in the warm, already-sent prompt-cache prefix: re-writing it costs the * cacheWrite premium on the whole suffix. Per-turn passes only reclaim inside * this tail (matches the supersede pass's default `suffixTokenLimit`); deeper * stale/age victims are left to compaction/shake, which rebuild the cache anyway. */ const PRUNE_CACHE_WARM_SUFFIX_TOKENS = 8_000; /** * Idle gap after which the supersede pass may flush the whole sent region (the * provider cache is cold, so re-writing it is free). MUST exceed the maximum * Anthropic prompt-cache TTL — "long" retention (the OAuth default) is 1h — or a * still-warm prefix is busted by the flush. 90 min leaves margin over the 1h TTL. */ const PRUNE_IDLE_FLUSH_MS = 90 * 60_000; /** * Hysteresis band for the post-maintenance "did we actually create headroom?" * check shared by the shake tail and the context-full / snapcompact tail. A * pass counts as having resolved threshold pressure only when residual context * lands at or below `COMPACTION_RECOVERY_BAND × threshold`. Re-checking against * the raw threshold lets a pass keep reclaiming a trickle of the previous * turn's output and land just under the line every turn, sustaining the * auto-continue dead loop reported in #2275; the same band stops the * context-full / snapcompact tail from re-firing on a history whose single * most-recent kept turn already exceeds the threshold (the snapcompact thrash). */ const COMPACTION_RECOVERY_BAND = 0.8; function mergeLlmCompactionPreserveData( hookPreserveData: Record | undefined, resultPreserveData: Record | undefined, ): Record | undefined { const preserveData = { ...(hookPreserveData ?? {}), ...(resultPreserveData ?? {}) }; return snapcompact.stripPreservedArchive(Object.keys(preserveData).length > 0 ? preserveData : undefined); } /** Capabilities borrowed from the owning AgentSession. */ export interface SessionMaintenanceHost { agent: Agent; sessionManager: SessionManager; settings: Settings; modelRegistry: ModelRegistry; extensionRunner: ExtensionRunner | undefined; sideStreamFn: StreamFn; providerSessionState: Map; model(): Model | undefined; thinkingLevel(): ThinkingLevel | undefined; isDisposed(): boolean; isStreaming(): boolean; isGeneratingHandoff(): boolean; promptGeneration(): number; sessionId(): string; messages(): AgentMessage[]; baseSystemPrompt(): string[]; goalModeState(): GoalModeState | undefined; planReferencePath(): string; nonMessageTokenSource(): NonMessageTokenSource; memoryBackendSession(): MemoryBackendOperationContext["session"]; emitSessionEvent(event: AgentSessionEvent): Promise; emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void; schedulePostPromptTask( task: (signal: AbortSignal) => Promise, options?: { delayMs?: number; generation?: number; onSkip?: (reason: "aborted" | "stale-generation") => void }, ): void; scheduleAgentContinue(options?: { delayMs?: number; generation?: number; shouldContinue?: () => boolean; onSkip?: ( reason: | "aborted" | "stale-generation" | "session-unavailable" | "should-continue-false" | "post-restore-unavailable", ) => void; onError?: () => void; }): void; scheduleCompactionContinuation(options: { generation: number; autoContinue: boolean; terminalTextAnswer: boolean; suppressContinuation: boolean; }): boolean; persistTurnMessagesForMidRunCompaction(context: AgentTurnEndContext | undefined): Promise; findLastAssistantMessage(): AssistantMessage | undefined; disconnectFromAgent(): void; reconnectToAgent(): void; drainStrandedQueuedMessages(): void; buildDisplaySessionContext(): SessionContext; convertToLlmForSideRequest(messages: AgentMessage[]): Message[]; obfuscateTextForProvider(text: string | undefined): string | undefined; obfuscatePreparationForProvider(preparation: CompactionPreparation): CompactionPreparation; closeCodexProviderSessionsForHistoryRewrite(): void; resetCodexProviderAfterCompaction(compaction: CodexCompactionContext): void; resetPlanReference(): void; syncTodoPhasesFromBranch(): void; resetAdvisorRuntimes(): void; rebaseAfterCompaction(): void; getContextBreakdown(options?: { contextWindow?: number; pendingMessages?: AgentMessage[]; }): ContextUsageBreakdown | undefined; getContextUsage(options?: { contextWindow?: number }): ContextUsage | undefined; shake(mode: ShakeMode, options?: { config?: ShakeConfig; signal?: AbortSignal }): Promise; dropImages(): Promise<{ removed: number }>; runHandoff(customInstructions?: string, options?: SessionHandoffOptions): Promise; removeAssistantMessageFromActiveContext(message: AssistantMessage): void; dropPersistedAssistantTurn(message: AssistantMessage): Promise; runRecoveryCompactionWithRollback( reason: "overflow" | "incomplete", message: AssistantMessage, allowDefer: boolean, options: { autoContinue: boolean; triggerContextTokens?: number }, ): Promise; parseRetryAfterMsFromError(errorMessage: string): number | undefined; setModelTemporary( model: Model, thinkingLevel?: ConfiguredThinkingLevel, options?: { ephemeral?: boolean }, ): Promise; abort(options?: { goalReason?: "interrupted" | "internal"; reason?: string; preserveCompaction?: boolean; }): Promise; abortHandoff(): void; } /** Owns compaction, pruning, shake, promotion, and automatic context maintenance. */ export class SessionMaintenance { #compactionAbortController: AbortController | undefined; #autoCompactionAbortController: AbortController | undefined; #skipPostTurnMaintenanceAssistantTimestamp: number | undefined; readonly #host: SessionMaintenanceHost; get #model(): Model | undefined { return this.#host.model(); } get #goalModeState(): GoalModeState | undefined { return this.#host.goalModeState(); } constructor(host: SessionMaintenanceHost) { this.#host = host; } /** Whether manual or automatic context maintenance is active. */ get isCompacting(): boolean { return this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined; } /** Assistant timestamp whose post-turn maintenance must be skipped once. */ get skipPostTurnMaintenanceAssistantTimestamp(): number | undefined { return this.#skipPostTurnMaintenanceAssistantTimestamp; } set skipPostTurnMaintenanceAssistantTimestamp(timestamp: number | undefined) { this.#skipPostTurnMaintenanceAssistantTimestamp = timestamp; } /** * Append plan-read protection to a prune/shake config so the active plan * file survives compaction alongside skill reads (the config defaults * already carry skill protection). The matcher reads the current plan * reference path at match time, so retitled plans are covered. */ #withPlanProtection(config: T): T { const planMatcher = createPlanReadMatcher(() => this.#host.planReferencePath()); return { ...config, protectedTools: [...config.protectedTools, planMatcher] }; } async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> { const branchEntries = this.#host.sessionManager.getBranch(); const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId; const result = pruneToolOutputs( branchEntries, this.#withPlanProtection({ ...DEFAULT_PRUNE_CONFIG, pruneUseless: this.#host.settings.getGroup("compaction").dropUseless, // Cache-stable boundary: never re-write the warm, already-sent prefix // (deep stale/age victims) or summarized-away entries every turn. keepBoundaryId, cacheWarmSuffixTokens: PRUNE_CACHE_WARM_SUFFIX_TOKENS, }), ); if (result.prunedCount === 0) { return undefined; } await this.#host.sessionManager.rewriteEntries(); const sessionContext = this.#host.buildDisplaySessionContext(); this.#host.agent.replaceMessages(sessionContext.messages); this.#host.resetAdvisorRuntimes(); this.#host.syncTodoPhasesFromBranch(); this.#host.closeCodexProviderSessionsForHistoryRewrite(); return result; } /** * Per-turn stale-result pass: prune older `read` results that a newer read * of the same file has made stale, plus results their tool flagged * contextually useless. Cache-aware (only fires when the suffix after a * candidate is small or the session has been idle long enough that the * provider prompt cache is cold), so it is cheap to run every turn. Gated * on the `compaction.supersedeReads` and `compaction.dropUseless` settings. * * Persists via `rewriteEntries` like every other history rewrite — the * session file must match the live (pruned) context or file-based forks * (`/fork`, `/tan`) and resume rebuild a divergent prefix and cold-miss the * provider prompt cache. */ async #pruneStaleToolResults(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> { const { supersedeReads, dropUseless } = this.#host.settings.getGroup("compaction"); if (!supersedeReads && !dropUseless) return undefined; const branchEntries = this.#host.sessionManager.getBranch(); const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId; const result = pruneSupersededToolResults( branchEntries, this.#withPlanProtection({ supersedeKey: supersedeReads ? readToolSupersedeKey : undefined, pruneUseless: dropUseless, protectedTools: [...DEFAULT_PRUNE_CONFIG.protectedTools], // Never re-write summarized-away entries; only flush the whole sent // region once the cache is genuinely cold (idle exceeds the 1h TTL). keepBoundaryId, idleFlushMs: PRUNE_IDLE_FLUSH_MS, }), ); if (result.prunedCount === 0) { return undefined; } await this.#host.sessionManager.rewriteEntries(); const sessionContext = this.#host.buildDisplaySessionContext(); this.#host.agent.replaceMessages(sessionContext.messages); this.#host.resetAdvisorRuntimes(); this.#host.syncTodoPhasesFromBranch(); this.#host.closeCodexProviderSessionsForHistoryRewrite(); return result; } /** * Strip image content blocks from every message on the current branch and * persist the rewrite. Walks `SessionManager.getBranch()` in place — both * `SessionMessageEntry.message` and `CustomMessageEntry.content` arrays * are mutated, then `rewriteEntries` durably commits the new shape. The * agent's runtime view is rebuilt from the freshly-mutated entries so any * provider sessions caching message identity (Codex Responses) are torn * down to force a clean replay on the next turn. * * No-op when the branch carries no images; returns `{ removed: 0 }` and * skips the disk rewrite. */ async dropImages(): Promise<{ removed: number }> { const branchEntries = this.#host.sessionManager.getBranch(); let removed = 0; for (const entry of branchEntries) { if (entry.type === "message") { removed += stripImagesFromMessage(entry.message); continue; } if (entry.type === "custom_message" && typeof entry.content !== "string") { const kept: typeof entry.content = []; let dropped = 0; for (const part of entry.content) { if (part.type === "image") { dropped++; } else { kept.push(part); } } if (dropped > 0) { if (kept.length === 0) { kept.push({ type: "text", text: "[image removed]" }); } entry.content = kept; removed += dropped; } } } if (removed === 0) { return { removed: 0 }; } await this.#host.sessionManager.rewriteEntries(); const sessionContext = this.#host.buildDisplaySessionContext(); this.#host.agent.replaceMessages(sessionContext.messages); this.#host.resetAdvisorRuntimes(); this.#host.closeCodexProviderSessionsForHistoryRewrite(); return { removed }; } /** * Surgically reduce context by dropping heavy content ("shake"). * * - `images` delegates to {@link dropImages}. * - `elide` replaces whole tool-call results and large fenced/XML blocks * with short placeholders that embed an `artifact://` recovery link. * * Mutates the branch in place, persists via `rewriteEntries`, replays the * rebuilt context through the agent, and tears down provider sessions that * cache message identity — same rewrite contract as {@link dropImages}. * * No-op (zero counts) when nothing is eligible. */ async shake(mode: ShakeMode, opts: { config?: ShakeConfig; signal?: AbortSignal } = {}): Promise { if (mode === "images") { const { removed } = await this.#host.dropImages(); return { mode, toolResultsDropped: 0, blocksDropped: 0, imagesDropped: removed, tokensFreed: 0 }; } const branchEntries = this.#host.sessionManager.getBranch(); const config = this.#withPlanProtection({ ...(opts.config ?? AGGRESSIVE_SHAKE_CONFIG), // Skip entries summarized away by the latest compaction — shaking them // only churns persisted history with no prompt/cache effect. keepBoundaryId: getLatestCompactionEntry(branchEntries)?.firstKeptEntryId, }); const regions = collectShakeRegions(branchEntries, config); if (regions.length === 0) { return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }; } const artifactId = await this.#saveShakeArtifact(regions); const replacements = regions.map((region, index) => this.#shakeElidePlaceholder(region, index, artifactId)); let toolResultsDropped = 0; let blocksDropped = 0; let originalTokens = 0; let replacementTokens = 0; const items = regions.map((region, index) => { if (region.kind === "toolResult") toolResultsDropped++; else blocksDropped++; originalTokens += region.tokens; const replacement = replacements[index]; if (replacement.length > 0) replacementTokens += countTokens(replacement); return { region, replacement }; }); applyShakeRegions(items); await this.#host.sessionManager.rewriteEntries(); const sessionContext = this.#host.buildDisplaySessionContext(); this.#host.agent.replaceMessages(sessionContext.messages); this.#host.resetAdvisorRuntimes(); this.#host.closeCodexProviderSessionsForHistoryRewrite(); return { mode, toolResultsDropped, blocksDropped, tokensFreed: Math.max(0, originalTokens - replacementTokens), artifactId, }; } #shakeElidePlaceholder(region: ShakeRegion, index: number, artifactId: string | undefined): string { if (artifactId) { return `[shaken ~${region.tokens} tokens — recover: artifact://${artifactId} (region ${index + 1})]`; } return `[shaken ~${region.tokens} tokens]`; } /** * Concatenate the original region contents into one session artifact so the * agent can read them back via `artifact://`. Returns `undefined` when * the session is not persisted or the write fails — callers degrade to a * bare placeholder. */ async #saveShakeArtifact(regions: ShakeRegion[]): Promise { const parts: string[] = []; for (let i = 0; i < regions.length; i++) { const region = regions[i]; parts.push(`### region ${i + 1} (${region.label}, ~${region.tokens} tok)`, "", region.originalText, ""); } try { return await this.#host.sessionManager.saveArtifact(parts.join("\n"), "shake"); } catch { return undefined; } } /** * Manually compact the session context. * Aborts current agent operation first. * @param customInstructions Optional instructions for the compaction summary * @param options Optional callbacks for completion/error handling */ async compact(customInstructions?: string, options?: CompactOptions): Promise { if (this.#compactionAbortController) { throw new Error("Compaction already in progress"); } // Resolve the `/compact ` subcommand up front so input validation // runs before we disconnect/abort the active agent operation below. const compactMode = options?.mode ? findCompactMode(options.mode) : undefined; // Modes that produce no LLM summary (snapcompact) have nothing to focus. // Reject focus text loudly so programmatic callers don't silently lose // instructions (the slash path pre-validates via parseCompactArgs). // `internalGuidance` counts the same way — plan-mode approval never // combines with a rejects-focus mode, but reject early if a caller ever // wires it up so we don't silently drop the directive on the snapcompact // fallback (issue #4359). if (compactMode?.rejectsFocus && (customInstructions || options?.internalGuidance)) { throw new Error(`/compact ${compactMode.name} does not take focus instructions.`); } const compactionAbortController = new AbortController(); this.#compactionAbortController = compactionAbortController; try { this.#host.disconnectFromAgent(); await this.#host.abort({ goalReason: "internal", preserveCompaction: true }); if (!this.#model) { throw new Error("No model selected"); } const compactionSettings = this.#host.settings.getGroup("compaction"); // The `/compact ` override (resolved above) replaces the configured // strategy/remote flags for this one invocation. Merged before // prepareCompaction so the remote gating (preparation.settings. // remoteEnabled/endpoint) and the snapcompact decision below both see it. const effectiveSettings = compactMode ? { ...compactionSettings, ...compactMode.overrides } : compactionSettings; // /compact remote demands provider-native compaction. When no remote // endpoint is configured (one would override per-model gating in // compact()), drop fallback candidates that aren't remote-capable so the // engine never silently runs a local summary on a configured-but-non- // remote compactionModel. If filtering empties the chain, warn and fall // back to the full chain so the operation still completes. const availableModels = this.#host.modelRegistry.getAvailable(); const requireProviderRemote = Boolean(compactMode?.requiresRemote && !effectiveSettings.remoteEndpoint); let compactionCandidates = this.#getCompactionModelCandidates( availableModels, requireProviderRemote ? shouldUseOpenAiRemoteCompaction : undefined, ); if (requireProviderRemote && compactionCandidates.length === 0) { this.#host.emitNotice( "warning", `remote compaction is unavailable for ${this.#model.id} (no remote endpoint configured and no provider-native remote-capable model in the fallback chain) — using a local summary instead`, "compaction", ); compactionCandidates = this.#getCompactionModelCandidates(availableModels); } const pathEntries = this.#host.sessionManager.getBranch(); const preparation = prepareCompaction(pathEntries, effectiveSettings, this.#model); 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)"); } let hookCompaction: CompactionResult | undefined; let fromExtension = false; let preserveData: Record | undefined; if (this.#host.extensionRunner?.hasHandlers("session_before_compact")) { const result = (await this.#host.extensionRunner.emit({ type: "session_before_compact", preparation, branchEntries: pathEntries, customInstructions, signal: compactionAbortController.signal, })) as SessionBeforeCompactResult | undefined; if (result?.cancel) { throw new CompactionCancelledError(); } if (result?.compaction) { hookCompaction = result.compaction; fromExtension = true; } } const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction); // Strategy honored on manual /compact too. Custom instructions (public // user focus OR internal plan-mode guidance) imply a directed LLM // summary; a text-only model cannot read snapcompact frames. const wantsSnapcompact = compactionPrep.kind !== "fromHook" && effectiveSettings.strategy === "snapcompact" && !customInstructions && !options?.internalGuidance; // `/compact snapcompact` is an explicit no-LLM archive request: honor // its contract by failing locally rather than silently shipping the // transcript to a provider. The default-configured snapcompact // strategy, in contrast, falls back to LLM compaction (mirroring the // auto-compaction path) so a routine /compact still completes on a // text-only model (issue #5064). const explicitSnapcompact = compactMode?.name === "snapcompact"; let snapcompactReady = wantsSnapcompact; const snapcompactShapeSetting = this.#host.settings.get("snapcompact.shape"); let snapcompactShape: snapcompact.Shape | undefined; // Claude refuses inputs that reproduce its own reasoning as text // ("reasoning_extraction"), and the snapcompact archive is replayed as // text into every later request; drop `¶think:` sections for // Anthropic-dialect targets (issue #6093). const snapcompactIncludeThinking = preferredDialect(this.#model.id) !== "anthropic"; if (wantsSnapcompact && !this.#model.input.includes("image")) { if (explicitSnapcompact) { this.#host.emitNotice( "warning", `snapcompact needs a vision-capable model (${this.#model.id} is text-only)`, "compaction", ); throw new Error(`snapcompact cannot run locally: ${this.#model.id} is text-only.`); } this.#host.emitNotice( "warning", `snapcompact needs a vision-capable model (${this.#model.id} is text-only); falling back to LLM compaction`, "compaction", ); snapcompactReady = false; } else if (snapcompactReady) { const text = snapcompact.serializeConversation( convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)), { includeThinking: snapcompactIncludeThinking }, ); const probeText = snapcompact.renderabilityProbeText( text, preparation.previousPreserveData, preparation.previousSummary, ); snapcompactShape = snapcompact.resolveShapeForText(probeText, this.#model, snapcompactShapeSetting); const renderScan = snapcompact.scanRenderability(probeText, { shape: snapcompactShape }); if (!renderScan.isSafe) { const percent = (renderScan.unrenderableRatio * 100).toFixed(1); this.#host.emitNotice( "warning", `snapcompact disabled: unsupported characters for selected snapcompact font (${percent}%). No LLM fallback was attempted.`, "compaction", ); throw new Error( `snapcompact cannot render this conversation locally: unsupported characters for selected snapcompact font (${percent}%).`, ); } } let summary: string; let shortSummary: string | undefined; let firstKeptEntryId: string; let tokensBefore: number; let details: unknown; let codexCompaction: CodexCompactionContext | undefined; // Snapcompact runs locally first. The frame cap is sized from the live // model window via #computeSnapcompactMaxFrames so the post-render context // fits without the warning loop (issue #3247). Zero-frame budget now fails // the snapcompact request locally rather than falling back to an LLM call. let snapcompactResult: snapcompact.CompactionResult | undefined; if (snapcompactReady) { const maxFrames = this.#computeSnapcompactMaxFrames(preparation, effectiveSettings); if (maxFrames < 1) { logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", { model: this.#model?.id, }); this.#host.emitNotice( "warning", "snapcompact: kept history alone exceeds the context budget. No LLM fallback was attempted.", "compaction", ); throw new Error("snapcompact cannot run locally: kept history alone exceeds the context budget."); } else { const shape = snapcompactShape; if (!shape) { throw new Error("snapcompact shape was not resolved before rendering."); } snapcompactResult = await snapcompact.compact(preparation, { convertToLlm, model: this.#model, ...(snapcompactShapeSetting === "auto" ? {} : { shape }), maxFrames, includeThinking: snapcompactIncludeThinking, }); const framePayloadBytes = this.#snapcompactFramePayloadBytes(snapcompactResult); if (framePayloadBytes > snapcompact.FRAME_DATA_BYTES_BUDGET) { logger.warn("Snapcompact exceeded the per-request frame payload budget", { model: this.#model?.id, framePayloadBytes, budget: snapcompact.FRAME_DATA_BYTES_BUDGET, }); this.#host.emitNotice( "warning", "snapcompact produced too much standing image payload. No LLM fallback was attempted.", "compaction", ); throw new Error( "snapcompact cannot run locally: standing image payload exceeds the per-request budget.", ); } const ctxWindow = this.#model?.contextWindow ?? 0; const budget = ctxWindow > 0 ? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings) : Number.POSITIVE_INFINITY; if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) { logger.warn("Snapcompact still overflows the window after frame-budget sizing", { model: this.#model?.id, }); this.#host.emitNotice( "warning", "snapcompact could not bring the context under the limit. No LLM fallback was attempted.", "compaction", ); throw new Error("snapcompact could not bring the context under the limit locally."); } } } if (compactionPrep.kind === "fromHook") { summary = compactionPrep.summary; shortSummary = compactionPrep.shortSummary; firstKeptEntryId = compactionPrep.firstKeptEntryId; tokensBefore = compactionPrep.tokensBefore; details = compactionPrep.details; preserveData = compactionPrep.preserveData; } else if (snapcompactResult) { summary = snapcompactResult.summary; shortSummary = snapcompactResult.shortSummary; firstKeptEntryId = snapcompactResult.firstKeptEntryId; tokensBefore = snapcompactResult.tokensBefore; details = snapcompactResult.details; preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) }; } else { codexCompaction = createCodexCompactionContext({ trigger: "manual", reason: "user_requested", phase: "standalone_turn", }); // Generate compaction result. Only convert known abort-shaped // rejections (AbortError raised while the abort signal is set, // or an already-typed sentinel) into `CompactionCancelledError` // so downstream callers can discriminate cancel from generic // failure via `instanceof` without inspecting message strings. // Real compaction bugs (network, server, parsing, etc.) keep // their original shape — they must not be silently relabeled // as cancellations even if the signal happens to be aborted // for an unrelated reason. Assignments live inside the try // block because every catch path throws — the post-try reads // of the result-derived locals are reachable only on success. try { const result = await this.#compactWithFallbackModel( preparation, options?.internalGuidance ?? customInstructions, compactionAbortController.signal, { promptOverride: this.#host.obfuscateTextForProvider(compactionPrep.hookPrompt), extraContext: compactionPrep.hookContext, remoteInstructions: this.#host.baseSystemPrompt().join("\n\n"), convertToLlm: messages => this.#host.convertToLlmForSideRequest(messages), codexCompaction, }, compactionCandidates, ); summary = result.summary; shortSummary = result.shortSummary; firstKeptEntryId = result.firstKeptEntryId; tokensBefore = result.tokensBefore; details = result.details; preserveData = mergeLlmCompactionPreserveData(compactionPrep.preserveData, result.preserveData); } catch (err) { if (err instanceof CompactionCancelledError) { throw err; } if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") { throw new CompactionCancelledError(); } throw err; } } if (compactionAbortController.signal.aborted) { throw new CompactionCancelledError(); } this.#host.sessionManager.appendCompaction( summary, shortSummary, firstKeptEntryId, tokensBefore, details, fromExtension, preserveData, ); const newEntries = this.#host.sessionManager.getEntries(); const sessionContext = this.#host.buildDisplaySessionContext(); this.#host.agent.replaceMessages(sessionContext.messages); this.#host.rebaseAfterCompaction(); // Compaction discarded the conversation history that carried the approved // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads // the plan from disk and re-injects it on the next turn (issue #1246). this.#host.resetPlanReference(); this.#host.resetAdvisorRuntimes(); this.#host.syncTodoPhasesFromBranch(); if (codexCompaction) { this.#host.resetCodexProviderAfterCompaction(codexCompaction); } else { this.#host.closeCodexProviderSessionsForHistoryRewrite(); } // Get the saved compaction entry for the hook const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as | CompactionEntry | undefined; if (this.#host.extensionRunner && savedCompactionEntry) { await this.#host.extensionRunner.emit({ type: "session_compact", compactionEntry: savedCompactionEntry, fromExtension, }); } const compactionResult: CompactionResult = { summary, shortSummary, firstKeptEntryId, tokensBefore, details, preserveData, }; options?.onComplete?.(compactionResult); return compactionResult; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); options?.onError?.(err); throw error; } finally { if (this.#compactionAbortController === compactionAbortController) { this.#compactionAbortController = undefined; } this.#host.reconnectToAgent(); // Compaction disconnected before `await abort()`, so abort's finally drain // (and any steer/follow-up that arrived mid-compaction — async IRC, an // `xd://` mount notice, an SDK/RPC steer) was suppressed while disconnected // (issue #5800). Unlike `/new`/switchSession, compaction preserves the agent // queues, so nothing else resumes them: re-drain now that the listener is back // and `isCompacting` is false, or the queued turn hangs until the next prompt. this.#host.drainStrandedQueuedMessages(); } } /** * Ask the active memory backend for an extra-context block to splice into * the compaction summary prompt. Both the manual and auto compaction paths * funnel through this helper so the behaviour stays identical. * * Failures are swallowed: a memory backend going sideways MUST NOT block * compaction (which is itself the recovery path for context overflow). */ async #collectMemoryBackendContext(preparation: { messagesToSummarize: AgentMessage[]; turnPrefixMessages: AgentMessage[]; }): Promise { const backend = await resolveMemoryBackend(this.#host.settings); if (!backend.preCompactionContext) return undefined; const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); try { return await backend.preCompactionContext(messages, this.#host.settings, this.#host.memoryBackendSession()); } catch (err) { logger.debug("Memory backend preCompactionContext failed", { backend: backend.id, error: String(err), }); return undefined; } } /** * Cancel in-progress context maintenance (manual compaction, auto-compaction, or auto-handoff). */ abortCompaction(): void { this.#compactionAbortController?.abort(); this.#autoCompactionAbortController?.abort(); this.#host.abortHandoff(); } /** Cancel only automatic maintenance while preserving a manual compaction. */ abortAutomaticCompaction(): void { this.#autoCompactionAbortController?.abort(); } /** Trigger idle compaction through the auto-compaction flow (with UI events). */ async runIdleCompaction(): Promise { if (this.#host.isStreaming() || this.isCompacting) return; await this.runAutoCompaction("idle", false, true); } /** * Local token estimate of the stored conversation (plus any pending messages), * independent of provider-reported usage. A `before_provider_request` hook * (e.g. a compression extension such as Headroom) or other on-wire payload * transform can shrink the request below the real stored conversation; the * provider then reports deflated prompt tokens, so anchoring the compaction * decision purely on that usage lets the real history grow unbounded until it * overflows and native compaction can no longer run. This estimate is the * floor the compaction decision respects so on-wire compression can never * suppress it. */ #estimateStoredContextTokens(pendingMessages: AgentMessage[] = []): number { // Exclude encrypted reasoning (thinkingSignature / redactedThinking): its // local byte size diverges from what the provider bills, so counting it here // would let a thinking-heavy turn falsely trip the floor. The provider usage // (the other arm of compactionContextTokens) already accounts for it. const opts = { excludeEncryptedReasoning: true } as const; return ( computeNonMessageTokens(this.#host.nonMessageTokenSource()) + this.#host.messages().reduce((sum, msg) => sum + estimateTokens(msg, opts), 0) + pendingMessages.reduce((sum, msg) => sum + estimateTokens(msg, opts), 0) ); } #estimatePrePromptContextTokens(messages: AgentMessage[], contextWindow: number): number { const breakdown = this.#host.getContextBreakdown({ contextWindow, pendingMessages: messages }); const localEstimate = this.#estimateStoredContextTokens(messages); // Floor by the local estimate: a payload-shrinking before_provider_request // hook deflates the provider-anchored breakdown, which must not suppress // pre-prompt compaction (see #estimateStoredContextTokens). return compactionContextTokens(breakdown?.usedTokens ?? 0, localEstimate); } async runPrePromptCompactionIfNeeded(messages: AgentMessage[]): Promise { const model = this.#model; if (!model) return; const contextWindow = model.contextWindow ?? 0; if (contextWindow <= 0) return; const compactionSettings = this.#host.settings.getGroup("compaction"); const contextTokens = this.#estimatePrePromptContextTokens(messages, contextWindow); if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return; // Auto-promote first: switching to a larger-context model avoids compacting // the history at all. The post-turn threshold path already promotes before // compacting; without this, the pre-prompt path would pre-empt promotion and // compact (snapcompact/summary) a session that should have just been promoted. if (await this.#promoteContextModel()) { logger.debug("Pre-prompt context promotion avoided compaction", { contextTokens, contextWindow, model: `${model.provider}/${model.id}`, }); return; } logger.debug("Pre-prompt context maintenance triggered by pending prompt size", { contextTokens, contextWindow, model: `${model.provider}/${model.id}`, }); await this.runAutoCompaction("threshold", false, false, false, { autoContinue: false, triggerContextTokens: contextTokens, phase: "pre_turn", }); } /** * Compact continuing tool-loop runs before the next provider request. * * `onTurnEnd` is the safe boundary: tool results for the just-finished turn * are already paired in `activeMessages`, the live array the agent loop reads * before its next model call. Before compacting, the just-finished turn is * synchronously persisted if async message hooks have not reached the normal * append path yet. Mid-run handoff is suppressed because resetting the session * while the loop owns `activeMessages` would race the next request; handoff * strategy falls back to in-place context-full compaction here. */ async maintainContextMidRun( activeMessages: AgentMessage[], signal: AbortSignal | undefined, context: AgentTurnEndContext | undefined, ): Promise { if ( signal?.aborted || this.#host.isDisposed() || this.isCompacting || this.#host.isGeneratingHandoff() || !context?.willContinue ) return; const model = this.#model; const contextWindow = model?.contextWindow ?? 0; if (contextWindow <= 0) return; const compactionSettings = this.#host.settings.getGroup("compaction"); if ( !compactionSettings.enabled || compactionSettings.strategy === "off" || compactionSettings.midTurnEnabled === false ) { return; } const lastAssistant = [...activeMessages] .reverse() .find((message): message is AssistantMessage => message.role === "assistant"); if (!lastAssistant || lastAssistant.stopReason === "aborted" || lastAssistant.stopReason === "error") return; if (!(await this.#host.persistTurnMessagesForMidRunCompaction(context))) return; const billedContextTokens = calculateContextTokens(lastAssistant.usage); const storedContextTokens = this.#estimateStoredContextTokens(); const contextTokens = compactionContextTokens(billedContextTokens, storedContextTokens); if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return; // Promote to a larger-context sibling before compacting, mirroring the // pre-prompt (runPrePromptCompactionIfNeeded) and post-turn threshold // (checkCompaction) paths. Without this, a long mid-turn tool loop that // crosses the threshold compacts the history (and can hit the no-progress // dead-end on a single oversized turn) on a model that should have just // been promoted to a larger window instead. if (await this.#promoteContextModel()) { logger.debug("Mid-run context promotion avoided compaction", { contextTokens, contextWindow, from: `${model?.provider}/${model?.id}`, }); return; } const messagesBefore = activeMessages.length; await this.runAutoCompaction("threshold", false, false, false, { autoContinue: false, suppressContinuation: true, suppressHandoff: true, triggerContextTokens: contextTokens, phase: "mid_turn", }); if (signal?.aborted) return; const compactedMessages = this.#host.agent.state.messages; if (compactedMessages !== activeMessages) { activeMessages.splice(0, activeMessages.length, ...compactedMessages); } logger.debug("Mid-run compaction ran between provider calls", { contextTokens, contextWindow, strategy: compactionSettings.strategy, goalActive: this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active", messagesBefore, messagesAfter: activeMessages.length, }); } /** * Check if context maintenance or promotion is needed and run it. * Called after agent_end and before prompt submission. * * Four cases (in order): * 1. Input overflow + promotion: promote to larger model, retry without maintenance. * 2. Input overflow + no promotion target: run context maintenance, auto-retry on same model. * 3. Output incomplete (stopReason === "length", e.g. `response.incomplete`): the * model burned its output budget without producing an actionable deliverable * (reasoning-only or truncated). Drop the dead turn, try promotion, otherwise * run compaction/handoff and retry. * 4. Threshold: context over threshold, run context maintenance (no auto-retry). * * @param assistantMessage The assistant message to check * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true * @param allowDefer If true, threshold-driven handoff strategy may schedule itself as a * deferred post-prompt task instead of running inline. Callers running inside the * `agent_end` handler set this to true so `session.prompt()` resolves cleanly; callers * on the pre-prompt path (where the next agent turn is about to start) set it to false * to avoid racing the deferred handoff against the new turn. * @param autoContinue Whether maintenance may schedule the agent-authored continuation prompt. * @returns whether compaction/recovery scheduled a handoff, retry, auto-continue, or * queued-message drain that already owns the next turn. Callers MUST skip * `session_stop` and other agent continuations when `continuationScheduled` * is true. */ async checkCompaction( assistantMessage: AssistantMessage, skipAbortedCheck = true, allowDefer = true, autoContinue = true, ): Promise { // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return COMPACTION_CHECK_NONE; const contextWindow = this.#model?.contextWindow ?? 0; const generation = this.#host.promptGeneration(); // Skip overflow check if the message came from a different model. // This handles the case where user switched from a smaller-context model (e.g. opus) // to a larger-context model (e.g. codex) - the overflow error from the old model // shouldn't trigger compaction for the new model. const sameModel = this.#model && assistantMessage.provider === this.#model.provider && assistantMessage.model === this.#model.id; // This handles the case where an error was kept after compaction (in the "kept" region). // The error shouldn't trigger another compaction since we already compacted. // Example: opus fails -> switch to codex -> compact -> switch back to opus -> opus error // is still in context but shouldn't trigger compaction again. const compactionEntry = getLatestCompactionEntry(this.#host.sessionManager.getBranch()); const errorIsFromBeforeCompaction = compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime(); if (sameModel && !errorIsFromBeforeCompaction && AIError.isContextOverflow(assistantMessage, contextWindow)) { // Clear the failed turn from active context so the retry (or the next // user prompt) does not replay it. The persisted branch entry stays // for now: when no recovery path runs, the user-facing transcript // MUST keep the only assistant message explaining why the turn // stopped. The branch entry is dropped further down, but only on the // paths that actually schedule a retry/compaction. this.#host.removeAssistantMessageFromActiveContext(assistantMessage); // Try context promotion first - switch to a larger model and retry without compacting const promoted = await this.#tryContextPromotion(assistantMessage); if (promoted) { await this.#host.dropPersistedAssistantTurn(assistantMessage); // Retry on the promoted (larger) model without compacting this.#host.scheduleAgentContinue({ delayMs: 100, generation }); return COMPACTION_CHECK_CONTINUATION; } // No promotion target available fall through to compaction const compactionSettings = this.#host.settings.getGroup("compaction"); if (compactionSettings.enabled && compactionSettings.strategy !== "off") { return await this.#host.runRecoveryCompactionWithRollback("overflow", assistantMessage, allowDefer, { autoContinue, }); } return COMPACTION_CHECK_NONE; } // A context promotion can land while the failing call is already in // flight (or on a run whose loop predates the switch): the overflow // error then arrives stamped with the pre-promotion model while // `this.#host.model()` is already the promoted target. The sameModel guard // above deliberately ignores stale foreign-model errors, but this // state is not stale — recover exactly like the promotion path: // drop the dead turn and retry on the already-promoted model. Gated // narrowly on "current model IS the failed model's promotion target // with a strictly larger window" so genuinely stale errors from // old user-switched models keep surfacing untouched. if ( !sameModel && autoContinue && !errorIsFromBeforeCompaction && assistantMessage.stopReason === "error" && this.#model && contextWindow > 0 && this.#host.settings.getGroup("contextPromotion").enabled ) { const failedModel = this.#host.modelRegistry.find(assistantMessage.provider, assistantMessage.model); const failedWindow = failedModel?.contextWindow ?? 0; const promotionTarget = failedModel ? resolveContextPromotionConfiguredTarget(failedModel, this.#host.modelRegistry.getAvailable()) : undefined; if ( failedModel && failedWindow > 0 && contextWindow > failedWindow && promotionTarget && modelsAreEqual(promotionTarget, this.#model) && AIError.isContextOverflow(assistantMessage, failedWindow) ) { this.#host.removeAssistantMessageFromActiveContext(assistantMessage); await this.#host.dropPersistedAssistantTurn(assistantMessage); logger.debug("Overflow on pre-promotion model; retrying on promoted model", { failed: `${assistantMessage.provider}/${assistantMessage.model}`, current: `${this.#model.provider}/${this.#model.id}`, }); this.#host.scheduleAgentContinue({ delayMs: 100, generation }); return COMPACTION_CHECK_CONTINUATION; } } // Case 3: Output-side incomplete — `response.incomplete` from OpenAI Responses // (and Codex) maps to stopReason === "length". The model burned its // `max_output_tokens` budget on reasoning/text and emitted no actionable // deliverable. Same recovery class as overflow: promotion if available, // otherwise compaction/handoff. Unlike overflow, the *input* is fine, so we // allow the handoff strategy to actually run. if (sameModel && !errorIsFromBeforeCompaction && assistantMessage.stopReason === "length") { // Same active-context vs persisted-history split as the overflow path // above: clear the dead turn from agent state so it cannot be replayed, // but keep it on the branch unless promotion or compaction actually runs. this.#host.removeAssistantMessageFromActiveContext(assistantMessage); const promoted = await this.#tryContextPromotion(assistantMessage); if (promoted) { await this.#host.dropPersistedAssistantTurn(assistantMessage); logger.debug("Context promotion triggered by response.incomplete (length stop)", { from: `${assistantMessage.provider}/${assistantMessage.model}`, }); this.#host.scheduleAgentContinue({ delayMs: 100, generation }); return COMPACTION_CHECK_CONTINUATION; } const incompleteCompactionSettings = this.#host.settings.getGroup("compaction"); if (incompleteCompactionSettings.enabled && incompleteCompactionSettings.strategy !== "off") { logger.debug("Compaction triggered by response.incomplete (length stop, no promotion target)", { model: `${assistantMessage.provider}/${assistantMessage.model}`, strategy: incompleteCompactionSettings.strategy, }); return await this.#host.runRecoveryCompactionWithRollback("incomplete", assistantMessage, allowDefer, { autoContinue, triggerContextTokens: calculateContextTokens(assistantMessage.usage), }); } // Neither promotion nor compaction is available — surface the dead-end so // the user understands why the turn yielded with nothing. logger.warn("response.incomplete with no recovery path (promotion + compaction both unavailable)", { model: `${assistantMessage.provider}/${assistantMessage.model}`, }); return COMPACTION_CHECK_NONE; } // Stale-result pass runs every turn, before any threshold gating: it is // cheap (bails when no candidate) and independent of the compaction // setting. const supersedeResult = await this.#pruneStaleToolResults(); const compactionSettings = this.#host.settings.getGroup("compaction"); if (!compactionSettings.enabled || compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE; // Case 4: Threshold - turn succeeded but context is getting large // Skip if this was an error (non-overflow errors don't have usage data) if (assistantMessage.stopReason === "error") return COMPACTION_CHECK_NONE; const pruneResult = await this.#pruneToolOutputs(); const maintenanceTokensFreed = (supersedeResult?.tokensSaved ?? 0) + (pruneResult?.tokensSaved ?? 0); // `errorIsFromBeforeCompaction` (computed above) is the general // "this assistant message predates the latest compaction" predicate here, // not just an error-specific one; alias it locally so the threshold intent // reads clearly (#3412 review). const assistantPredatesCompaction = errorIsFromBeforeCompaction; // An assistant that predates the latest compaction carries stale, pre-rewrite // `usage`: the scheduled auto-continue re-enters this check with the kept // assistant (#promptWithMessage → checkCompaction), and its old high prompt // count would re-trip the threshold on a freshly compacted history. Drop the // stale provider number for those messages and let the live stored estimate // (the floor applied below) drive the decision instead. const assistantUsageContextTokens = assistantPredatesCompaction ? 0 : calculateContextTokens(assistantMessage.usage); const storedContextTokens = this.#estimateStoredContextTokens(); // Pruning frees bytes for the NEXT prompt; it does not change the size of // the prompt the LLM just billed for. Earlier revisions subtracted the // per-turn supersede/prune `tokensSaved` from the threshold input, which // let a long-running `/goal` session sit above `compaction.thresholdTokens` // indefinitely whenever per-turn pruning saved enough to drop the // post-prune estimate below the user-configured trigger — the visible // context (anchored to the same provider billing) still showed >threshold, // but `shouldCompact` no-op'd (#3174). Anchor the initial trigger on the // last turn's billed context tokens, floored by the post-prune // stored-conversation estimate so a payload-compression hook still can't // deflate the trigger. const contextTokens = compactionContextTokens(assistantUsageContextTokens, storedContextTokens); const postMaintenanceContextTokens = compactionContextTokens( Math.max(0, assistantUsageContextTokens - maintenanceTokensFreed), storedContextTokens, ); const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings); const shouldThresholdCompact = shouldCompact(contextTokens, contextWindow, compactionSettings); logger.debug("Auto-compaction threshold decision", { phase: "post-agent-end", goalModeEnabled: this.#goalModeState?.enabled === true, goalStatus: this.#goalModeState?.goal.status, stopReason: assistantMessage.stopReason, sameModel: sameModel === true, contextWindow, strategy: compactionSettings.strategy, thresholdTokens, assistantUsageContextTokens, storedContextTokens, resolvedContextTokens: contextTokens, postMaintenanceContextTokens, maintenanceTokensFreed, shouldCompact: shouldThresholdCompact, contextPromotionEnabled: this.#host.settings.get("contextPromotion.enabled") === true, }); if (shouldThresholdCompact) { // Try promotion first — if a larger model is available, switch instead of compacting const promoted = await this.#tryContextPromotion(assistantMessage); if (!promoted) { return await this.runAutoCompaction("threshold", false, false, allowDefer, { autoContinue, triggerContextTokens: postMaintenanceContextTokens, phase: "pre_turn", terminalTextAnswer: isTerminalTextAssistantAnswer(assistantMessage), }); } logger.debug("Auto-compaction threshold satisfied but context promotion took over", { contextTokens, contextWindow, model: `${assistantMessage.provider}/${assistantMessage.model}`, }); } return COMPACTION_CHECK_NONE; } /** * Attempt context promotion to a larger model. * Returns true if promotion succeeded (caller should retry without compacting). */ async #tryContextPromotion(assistantMessage: AssistantMessage): Promise { const currentModel = this.#model; if (!currentModel) return false; // The overflow/length error may have come from a model the user already // switched away from; only promote when the failing turn was this model. if (assistantMessage.provider !== currentModel.provider || assistantMessage.model !== currentModel.id) return false; return this.#promoteContextModel(); } /** * Switch to a larger-context sibling when context promotion is enabled and a * target with a strictly larger window (and a usable key) exists. Returns true * when the model was switched, so the caller can retry without compacting. * Message-independent core shared by the post-turn overflow path * ({@link #tryContextPromotion}) and the pre-prompt threshold path * ({@link runPrePromptCompactionIfNeeded}). */ async #promoteContextModel(): Promise { const promotionSettings = this.#host.settings.getGroup("contextPromotion"); if (!promotionSettings.enabled) return false; const currentModel = this.#model; if (!currentModel) return false; const contextWindow = currentModel.contextWindow ?? 0; if (contextWindow <= 0) return false; const targetModel = await this.resolveContextPromotionTarget(currentModel, contextWindow); if (!targetModel) return false; try { await this.#host.setModelTemporary(targetModel, undefined, { ephemeral: true }); logger.debug("Context promotion switched model on overflow", { from: `${currentModel.provider}/${currentModel.id}`, to: `${targetModel.provider}/${targetModel.id}`, }); return true; } catch (error) { logger.warn("Context promotion failed", { from: `${currentModel.provider}/${currentModel.id}`, to: `${targetModel.provider}/${targetModel.id}`, error: String(error), }); return false; } } async resolveContextPromotionTarget( currentModel: Model, contextWindow: number, signal?: AbortSignal, ): Promise { const availableModels = this.#host.modelRegistry.getAvailable(); if (availableModels.length === 0) return undefined; const candidate = resolveContextPromotionConfiguredTarget(currentModel, availableModels); if (!candidate) return undefined; if (modelsAreEqual(candidate, currentModel)) return undefined; if (candidate.contextWindow == null || candidate.contextWindow <= contextWindow) return undefined; const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId(), { signal }); if (!apiKey) return undefined; return candidate; } #getCompactionModelCandidates(availableModels: Model[], filter?: (model: Model) => boolean): Model[] { return this.resolveCompactionModelCandidates(this.#model, availableModels, filter); } resolveCompactionModelCandidates( preferredModel: Model | null | undefined, availableModels: Model[], filter?: (model: Model) => boolean, ): Model[] { const candidates: Model[] = []; const seen = new Set(); const addCandidate = (model: Model | undefined): void => { if (!model) return; const key = `${model.provider}/${model.id}`; if (seen.has(key)) return; seen.add(key); // `seen` still tracks rejected models so the largest-context fallback // scan below doesn't reintroduce them; the filter just suppresses // inclusion in this caller's candidate chain. if (filter && !filter(model)) return; candidates.push(model); }; if (preferredModel) { addCandidate(resolveCompactionConfiguredTarget(preferredModel, availableModels)); } addCandidate(preferredModel ?? undefined); for (const role of MODEL_ROLE_IDS) { addCandidate( resolveRoleModelFull(this.#host.settings, role, availableModels, preferredModel ?? undefined).model, ); } const sortedByContext = [...availableModels].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0)); for (const model of sortedByContext) { if (!seen.has(`${model.provider}/${model.id}`)) { addCandidate(model); break; } } return candidates; } #buildCompactionAuthError(): Error { const currentModel = this.#model; if (!currentModel) { return new Error( "Compaction requires a model with usable credentials, but no authenticated compaction model is available.", ); } return new Error( `Compaction requires usable credentials for ${currentModel.provider}/${currentModel.id}. ` + `Configure ${currentModel.provider} credentials or assign an authenticated fallback role such as modelRoles.smol.`, ); } async #compactWithFallbackModel( preparation: CompactionPreparation, customInstructions: string | undefined, signal: AbortSignal, options?: SummaryOptions, precomputedCandidates?: Model[], ): Promise { const candidates = precomputedCandidates ?? this.#getCompactionModelCandidates(this.#host.modelRegistry.getAvailable()); const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId()); for (const candidate of candidates) { const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId()); if (!apiKey) continue; try { return await compact( this.#host.obfuscatePreparationForProvider(preparation), candidate, this.#host.modelRegistry.resolver(candidate, this.#host.sessionId()), this.#host.obfuscateTextForProvider(customInstructions), signal, { ...options, metadata: this.#host.agent.metadataForProvider(candidate.provider), convertToLlm: messages => this.#host.convertToLlmForSideRequest(messages), telemetry, // Honor the user's /model thinking selection (incl. `off`) on // the manual `/compact` path. Clamped per-model inside compact() // via resolveCompactionEffort so unsupported-effort models // (xai-oauth/grok-build) don't trip requireSupportedEffort. thinkingLevel: this.#host.thinkingLevel(), tools: this.#host.agent.state.tools, sessionId: this.#host.sessionId(), promptCacheKey: this.#host.sessionId(), providerSessionState: this.#host.providerSessionState, // Route every summarization HTTP request through the // session's side-stream transport so the provider // concurrency cap (e.g. providers.ollama-cloud.maxConcurrency) // brackets compaction the same way it brackets the live // agent turn — without this, multiple ollama-cloud // subagents auto/manually compacting issued uncapped // summary requests in parallel (chatgpt-codex review on // #3751). completeImpl: async (requestModel, requestContext, requestOptions) => { const stream = await this.#host.sideStreamFn(requestModel, requestContext, requestOptions); return stream.result(); }, }, ); } catch (error) { if (!AIError.is(AIError.classify(error, candidate.api), AIError.Flag.AuthFailed)) { throw error; } } } throw this.#buildCompactionAuthError(); } async #prepareCompactionFromHooks( preparation: CompactionPreparation, hookCompaction: CompactionResult | undefined, ): Promise< | { kind: "fromHook"; summary: string; shortSummary: string | undefined; firstKeptEntryId: string; tokensBefore: number; details: unknown; preserveData: Record | undefined; } | { kind: "needsLlm"; hookContext: string[] | undefined; hookPrompt: string | undefined; preserveData: Record | undefined; } > { let hookContext: string[] | undefined; let hookPrompt: string | undefined; let preserveData: Record | undefined; if (!hookCompaction && this.#host.extensionRunner?.hasHandlers("session.compacting")) { const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); const result = (await this.#host.extensionRunner.emit({ type: "session.compacting", sessionId: this.#host.sessionId(), messages: compactMessages, })) as { context?: string[]; prompt?: string; preserveData?: Record } | undefined; hookContext = result?.context; hookPrompt = result?.prompt; preserveData = result?.preserveData; } const memoryBackendContext = await this.#collectMemoryBackendContext(preparation); if (memoryBackendContext) { hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext]; } if (hookCompaction) { preserveData ??= hookCompaction.preserveData; return { kind: "fromHook", summary: hookCompaction.summary, shortSummary: hookCompaction.shortSummary, firstKeptEntryId: hookCompaction.firstKeptEntryId, tokensBefore: hookCompaction.tokensBefore, details: hookCompaction.details, preserveData, }; } return { kind: "needsLlm", hookContext, hookPrompt, preserveData }; } /** * Cap on snapcompact frames the post-compaction context can carry without * busting the model window. Mirrors the per-frame token charge used by the * projection ({@link snapcompact.FRAME_TOKEN_ESTIMATE}, the conservative * high-res Anthropic ceiling), so picking `maxFrames` from this helper makes * {@link #projectSnapcompactContextTokens} succeed by construction. * * Skip vs. cap use different reserves on purpose. The **skip** decision * (return `0`) trips only when kept-recent plus non-message tokens already * eat the entire `ctxWindow − reserve` envelope: at that point no archive * shape — frame-bearing or text-only — can fit, and the caller MUST * shortcut to the LLM summarizer instead of re-running snapcompact to * re-emit the "could not bring the context under the limit" warning every * threshold tick. The **cap** calculation subtracts a shape-aware reserve * (`2 × geometry(shape).capacity` chars worth of text edges, billed at the * tiktoken cl100k baseline, plus a 2k summary-template allowance) sized * from the same `shape` snapcompact will use, so the projection still * passes once frames land — but it MUST NOT gate the skip decision, since * a frame-less archive (`text.length <= 2 * edgeCap` short-circuit in * `planArchive`) typically costs only a few hundred tokens of summary * lead and would fit under residual headroom far smaller than the cap * reserve (chatgpt-codex reviews on #3249). * * Returns `1` when the frame charge would overflow but the text-only path * still has room: snapcompact's planner picks the frame-less layout * automatically when the discarded text fits in the edges, so giving it * the minimum cap lets it succeed instead of being skipped outright. * * Without this cap, the bundled `MAX_FRAMES_DEFAULT = 80` × 5024 tokens = * ~402k frame-token projection always overflows any sub-1M-token window * (issue #3247). */ #computeSnapcompactMaxFrames(preparation: CompactionPreparation, settings: CompactionSettings): number { const ctxWindow = this.#model?.contextWindow ?? 0; if (ctxWindow <= 0) return Math.min(snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget()); const reserve = effectiveReserveTokens(ctxWindow, settings); let baseTokens = computeNonMessageTokens(this.#host.nonMessageTokenSource()); for (const message of preparation.recentMessages) { baseTokens += estimateTokens(message); } const totalBudget = ctxWindow - reserve; // Skip iff there is no headroom whatsoever; a text-only archive costs // far less than the cap reserve below, so any positive residual is // worth attempting and the projection guard catches actual overflow. if (baseTokens >= totalBudget) return 0; // Cap reserve mirrors what `estimateTokens(summaryMessage)` will charge // when frames > 0: `countTokens(summaryTemplate ‖ textHead ‖ textTail)` // plus `numFrames × FRAME_TOKEN_ESTIMATE`. Resolve the shape this // snapcompact pass will actually use (matches the `shape` argument // passed to `snapcompact.compact` in the auto and manual paths) so the // text-edge cost reflects the live frame geometry rather than a fixed // approximation. Reviewer (chatgpt-codex on #3249): a 4k reserve // undersized the ~7k text-edge cost on the default Anthropic // 11on16-bw shape, so the projection then rejected the `maxFrames` // the cap had picked and the warning loop reappeared. // // - `textHead` and `textTail` each consume up to `geometry.capacity` // chars when frames > 0 (one HQ-capacity page per edge: see // `TEXT_EDGE_PAGES = 1` in `planArchive`), so 2 × capacity chars // total. Per-shape capacity: Anthropic 11on16-bw ~13.9k, Opus // 1932px ~21k, Gemini 8on22-bw 2048px ~23.8k, OpenAI 1568px ~13.9k. // - tiktoken cl100k ≈ 4 chars/token on ASCII (verified empirically // for prose, code, and JSON); a 1.15 multiplier absorbs tokenizer // drift on denser content (e.g. dense JSON / tool-result blobs). // - Summary template (intro + FILES section + grid notes) bills // ~2k tokens for typical sessions. const shape = snapcompact.resolveShape(this.#model, this.#host.settings.get("snapcompact.shape")); const edgeCap = snapcompact.geometry(shape).capacity; const textEdgeTokens = Math.ceil((2 * edgeCap * 1.15) / 4); const SUMMARY_TEMPLATE_TOKENS = 2000; const capReserve = textEdgeTokens + SUMMARY_TEMPLATE_TOKENS; const frameBudget = totalBudget - baseTokens - capReserve; if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 1; return Math.min( Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE), snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget(), ); } #snapcompactFramePayloadBytes(result: snapcompact.CompactionResult): number { const archive = snapcompact.getPreservedArchive(result.preserveData); return archive ? snapcompact.frameDataBytes(archive.frames) : 0; } /** * Project the post-compaction context size of a snapcompact result: kept * recent messages + the summary message with its re-attached frames + the * fixed non-message overhead (system prompt + tools). Mirrors how the * compacted context is rebuilt, so the estimate matches the wire shape, and * lets the caller decide whether snapcompact brought the context under the * window or should fall back to an LLM summary. */ #projectSnapcompactContextTokens(preparation: CompactionPreparation, result: snapcompact.CompactionResult): number { const archive = snapcompact.getPreservedArchive(result.preserveData); const blocks = archive ? snapcompact.historyBlocks(archive, { maxFrameDataBytes: snapcompact.FRAME_DATA_BYTES_BUDGET }) : undefined; const summaryMessage = createCompactionSummaryMessage( result.summary, result.tokensBefore, new Date().toISOString(), result.shortSummary, undefined, undefined, blocks, ); let tokens = computeNonMessageTokens(this.#host.nonMessageTokenSource()) + estimateTokens(summaryMessage); for (const message of preparation.recentMessages) { tokens += estimateTokens(message); } return tokens; } /** * Post-maintenance progress check for the context-full / snapcompact tail. * * After `appendCompaction` rewrote history and `replaceMessages` swapped in the * compacted context, measure the residual context off the live message set and * decide whether maintenance actually created headroom. Mirrors the shake * recovery-band logic (#2275): a session whose single most-recent turn already * blows the threshold cannot be reduced by compaction (findCutPoint keeps that * turn verbatim), so re-firing on the next agent_end just thrashes. We only * report progress when residual context lands at or below * `COMPACTION_RECOVERY_BAND × threshold` — a band that sits strictly under the * compaction threshold, so reaching it guarantees the next turn cannot * re-trip threshold compaction. * * When the model/window is unknown we cannot evaluate the band, so we * optimistically allow the continuation (preserving prior behavior). */ #compactionCreatedHeadroom(): boolean { const contextWindow = this.#model?.contextWindow ?? 0; if (contextWindow <= 0) return true; const compactionSettings = this.#host.settings.getGroup("compaction"); const residualTokens = compactionContextTokens( this.#host.getContextUsage({ contextWindow })?.tokens ?? 0, this.#estimateStoredContextTokens(), ); const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings); const recoveryBand = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND); // Residual at/below the band is authoritative headroom: the band sits // strictly under the compaction threshold, so the next turn cannot // re-trip threshold compaction regardless of how little this pass shaved. // Don't add a secondary "smaller than the trigger" guard — when stale/ // tool-output pruning already dropped context under the band before this // pass, the trigger is itself sub-band, and requiring a strict reduction // would suppress a valid continuation and emit a false no-progress warning // even though compaction left the session safe. return residualTokens <= recoveryBand; } /** * Retry-side counterpart to {@link #compactionCreatedHeadroom}. An * overflow/incomplete recovery only needs the rebuilt prompt to *fit* the * window again — it does not have to land under the compaction threshold, let * alone the stricter `COMPACTION_RECOVERY_BAND × threshold` hysteresis the * auto-continue thrash guard uses. Reusing the band here turned recoverable * overflows into manual dead-ends: a 200k-window prompt compacted from * overflow down to ~150k is comfortably retryable, but sits above * `0.8 × 170k = 136k` and was wrongly refused (PR #3412 review). * * Measures residual context against the usable budget (`contextWindow - reserve`). * The default absolute reserve can exceed bundled small-context windows, or * nearly consume a 16k-class window; those known-impossible defaults fall * back to the proportional 15% reserve. Explicit valid reserves still define * the usable prompt budget so retries do not enter headroom the user * intentionally reserved. Callers MUST * invoke this AFTER dropping the failed assistant from `this.#host.messages()`, so * the just-failed turn (which the retry prompt will not include) is excluded * from the estimate. * * When the model/window is unknown we cannot evaluate the budget, so we * optimistically allow the retry (preserving prior behavior). */ #compactionCreatedRetryFit(): boolean { const contextWindow = this.#model?.contextWindow ?? 0; if (contextWindow <= 0) return true; const compactionSettings = this.#host.settings.getGroup("compaction"); const residualTokens = compactionContextTokens( this.#host.getContextUsage({ contextWindow })?.tokens ?? 0, this.#estimateStoredContextTokens(), ); const fitBudget = Math.max(0, contextWindow - resolveBudgetReserveTokens(contextWindow, compactionSettings)); return residualTokens <= fitBudget; } /** * Last-resort tiered reducer when {@link runAutoCompaction} would otherwise * dead-end. The summarizer cut at the only available turn boundary, but the * kept tail is still over the recovery band because a single recent turn (a * large tool-result, a heavy fenced/XML block, attached images) is itself * bigger than the band and `findCutPoint` cannot cut inside one message. * * Tier 1 — `shake("elide")` reaches INSIDE that tail: heavy tool-result / * block content is offloaded to one `artifact://` blob behind a recoverable * placeholder. Skipped when this pass already ran a shake (`skipElide`). * Tier 2 — `dropImages()`: the manual `/shake images` remedy, automated. * Image blocks are stripped from the branch; unlike elided text they are NOT * artifact-recoverable, so this tier only runs once elide has failed the * progress re-test. * * Each tier that rewrote history re-anchors the in-flight context snapshot, * then the caller's progress predicate is re-tested; the first tier that * restores progress emits one info notice describing everything freed and * stops. Returns whether progress was restored — `false` falls through to * the dead-end warning. */ async #rescueCompactionDeadEnd( signal: AbortSignal, options: { skipElide: boolean; hasProgress: () => boolean }, ): Promise { if (signal.aborted) return false; // Tier 0 — a snapcompact pass whose just-written frame archive is itself // the over-budget cost (each pass re-renders the carried-forward text // into MORE frames, so the archive grows past the recovery band and the // elide/image tiers below can never shrink it): rebuild the archive at // a threshold-derived frame budget. const frameRescue = await this.#rescueSnapcompactFrameOverflow( this.#host.sessionManager.getBranch(), this.#host.settings.getGroup("compaction"), signal, ); if (frameRescue !== undefined && options.hasProgress()) return true; let elided = 0; let elidedTokens = 0; let elideSink = "placeholders"; if (!options.skipElide) { try { const result = await this.#host.shake("elide", { signal }); elided = result.toolResultsDropped + result.blocksDropped; elidedTokens = result.tokensFreed; if (result.artifactId) elideSink = "an artifact"; if (elided > 0) { // The elide pass rewrote history; re-anchor the in-flight snapshot // so the caller's headroom/retry-fit re-test measures the shaken // context. this.#host.rebaseAfterCompaction(); } } catch (error) { logger.warn("Dead-end shake rescue failed", { error: error instanceof Error ? error.message : String(error), }); } if (elided > 0 && options.hasProgress()) { this.#host.emitNotice( "info", `Compaction dead-end recovery: ${this.#describeElideRescue(elided, elidedTokens, elideSink)} so maintenance could make progress.`, "compaction", ); return true; } } if (signal.aborted) return false; let imagesDropped = 0; try { imagesDropped = (await this.#host.dropImages()).removed; if (imagesDropped > 0) this.#host.rebaseAfterCompaction(); } catch (error) { logger.warn("Dead-end image-drop rescue failed", { error: error instanceof Error ? error.message : String(error), }); } if (imagesDropped > 0 && options.hasProgress()) { const elidedPart = elided > 0 ? `${this.#describeElideRescue(elided, elidedTokens, elideSink)} and ` : ""; this.#host.emitNotice( "info", `Compaction dead-end recovery: ${elidedPart}dropped ${imagesDropped} attached image${imagesDropped === 1 ? "" : "s"} so maintenance could make progress.`, "compaction", ); return true; } return false; } /** Notice fragment for a dead-end elide tier: what was freed and where it went. */ #describeElideRescue(elided: number, tokensFreed: number, sink: string): string { return `elided ${elided} heavy block${elided === 1 ? "" : "s"} (~${tokensFreed.toLocaleString()} tokens) to ${sink}`; } /** * Frame budget for {@link #rescueSnapcompactFrameOverflow}: targets * `COMPACTION_RECOVERY_BAND × threshold` (the same band * {@link #compactionCreatedHeadroom} re-tests), not the window-fit budget * {@link #computeSnapcompactMaxFrames} sizes against — a rebuilt archive * must land back under the maintenance trigger, or the next settle * re-enters the same dead-end. Cap reserve mirrors * #computeSnapcompactMaxFrames (text edges + summary template), and * `keptTailTokens` charges the kept entries AFTER the archive so the * budget mirrors what #compactionCreatedHeadroom will actually measure. * Returns 0 when not even one frame fits that budget — the rebuild could * never create headroom, so the caller must not append it. */ #computeSnapcompactRescueMaxFrames(settings: CompactionSettings, keptTailTokens: number): number { const ctxWindow = this.#model?.contextWindow ?? 0; if (ctxWindow <= 0) return Math.min(snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget()); const thresholdTokens = resolveThresholdTokens(ctxWindow, settings); const recoveryBandTokens = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND); const baseTokens = computeNonMessageTokens(this.#host.nonMessageTokenSource()); const shape = snapcompact.resolveShape(this.#model, this.#host.settings.get("snapcompact.shape")); const edgeCap = snapcompact.geometry(shape).capacity; const textEdgeTokens = Math.ceil((2 * edgeCap * 1.15) / 4); const SUMMARY_TEMPLATE_TOKENS = 2000; const frameBudget = recoveryBandTokens - baseTokens - keptTailTokens - textEdgeTokens - SUMMARY_TEMPLATE_TOKENS; if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 0; // Same hard caps as #computeSnapcompactMaxFrames: a threshold-derived // count above the per-request payload budget would "shrink" a huge // archive to a frame count the rebuilt prompt can never attach anyway. return Math.min( Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE), snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget(), ); } /** * Dead-end rescue for a branch whose latest snapcompact CompactionEntry is * itself billed past the maintenance threshold * (`FRAME_TOKEN_ESTIMATE × frames`). Reaching the `!preparation` dead-end * proves everything after that entry is already kept-recent (nothing to * summarize), so the archive is the irreducible cost — and the elide/image * tiers can never touch it: `collectShakeRegions` and `dropImages()` only * inspect "message"/"custom_message" entries, so a `type: "compaction"` * entry falls through both and the session re-warns on every resume (the * shape issue #4786's rescue does not cover). * * Rebuilds the SAME archive locally — no LLM, no network — by re-running * `snapcompact.compact()` over the entry's carried-forward source text at * a maxFrames derived from the trigger threshold instead of the window: * `planArchive` truncates the oldest chars to fit, so the rebuilt entry * genuinely shrinks. The rebuilt entry keeps the stale entry's * `firstKeptEntryId`, so the kept tail is untouched, and persisting * through `appendCompaction()` lets the write-time superseded-compaction * elision drop the stale frame payload from the JSONL automatically. */ async #rescueSnapcompactFrameOverflow( branchEntries: SessionEntry[], settings: CompactionSettings, signal: AbortSignal, ): Promise { if (signal.aborted) return undefined; // Re-rendering frames needs a vision-capable model, same gate as the // snapcompact strategy path. if (!this.#model?.input.includes("image")) return undefined; const staleEntry = getLatestCompactionEntry(branchEntries); if (!staleEntry) return undefined; // Only rescue when the archive is the actual source of the overflow. // The frame budget below charges every kept entry the rebuilt context // will still carry — the kept-recent region from `firstKeptEntryId` // (re-emitted before the archive by buildSessionContext) plus the // entries after the archive — on top of the fixed context, mirroring // what #compactionCreatedHeadroom will measure. When not even one // frame fits (e.g. a huge kept tool result dominates), rebuilding // would append the replacement compaction at the leaf — turning the // branch tail into a compaction entry, which prepareCompaction's // last-entry guard can never summarize past even after an elide // shrinks the real culprit. Bail and let the elide/image tiers handle // that tail instead. let keptTailTokens = 0; let inKeptRegion = false; for (const entry of branchEntries) { if (entry.id === staleEntry.firstKeptEntryId) inKeptRegion = true; if (entry.id === staleEntry.id) { // Everything after the archive is always kept. inKeptRegion = true; continue; } if (!inKeptRegion) continue; const message = (entry as { message?: AgentMessage }).message; if (message) keptTailTokens += estimateTokens(message); } const archive = snapcompact.getPreservedArchive(staleEntry.preserveData); if (!archive || archive.frames.length <= 1) return undefined; const archiveText = snapcompact.archiveSourceText(archive); if (!archiveText) return undefined; const maxFrames = this.#computeSnapcompactRescueMaxFrames(settings, keptTailTokens); if (maxFrames < 1 || maxFrames >= archive.frames.length) return undefined; const staleDetails = staleEntry.details as snapcompact.CompactionDetails | undefined; const fileOps = snapcompact.createFileOps(); for (const file of staleDetails?.readFiles ?? []) fileOps.read.add(file); for (const file of staleDetails?.modifiedFiles ?? []) fileOps.edited.add(file); const shapeSetting = this.#host.settings.get("snapcompact.shape"); const shape = snapcompact.resolveShapeForText(archiveText, this.#model, shapeSetting); let result: snapcompact.CompactionResult; try { result = await snapcompact.compact( { firstKeptEntryId: staleEntry.firstKeptEntryId, messagesToSummarize: [], turnPrefixMessages: [], tokensBefore: staleEntry.tokensBefore, previousSummary: staleEntry.summary, previousPreserveData: staleEntry.preserveData, fileOps, }, { convertToLlm, model: this.#model, ...(shapeSetting === "auto" ? {} : { shape }), maxFrames, }, ); } catch (error) { logger.warn("Dead-end snapcompact frame rescue failed", { error: error instanceof Error ? error.message : String(error), }); return undefined; } if (signal.aborted) return undefined; const rebuilt = snapcompact.getPreservedArchive(result.preserveData); if (!rebuilt || rebuilt.frames.length >= archive.frames.length) return undefined; const rebuiltEntryId = this.#host.sessionManager.appendCompaction( result.summary, result.shortSummary, result.firstKeptEntryId, result.tokensBefore, result.details, false, result.preserveData, ); const sessionContext = this.#host.buildDisplaySessionContext(); this.#host.agent.replaceMessages(sessionContext.messages); this.#host.rebaseAfterCompaction(); // Same post-rewrite bookkeeping as the regular compaction append: the // rebuilt context no longer carries the transient plan reference (#1246), // and advisor cursors / todo phases were derived from the replaced // history. this.#host.resetPlanReference(); this.#host.resetAdvisorRuntimes(); this.#host.syncTodoPhasesFromBranch(); this.#host.closeCodexProviderSessionsForHistoryRewrite(); // Extensions must see the entry that is now active, not (only) the one // this rebuild just superseded — mirror the regular append path's hook. const rebuiltEntry = this.#host.sessionManager.getEntries().find(e => e.id === rebuiltEntryId) as | CompactionEntry | undefined; if (this.#host.extensionRunner && rebuiltEntry) { await this.#host.extensionRunner.emit({ type: "session_compact", compactionEntry: rebuiltEntry, fromExtension: false, }); } this.#host.emitNotice( "info", `Compaction dead-end recovery: rebuilt the trailing snapcompact archive at a smaller frame budget (${archive.frames.length} → ${rebuilt.frames.length} frames) so maintenance could make progress.`, "compaction", ); return result; } /** * Internal: Run auto-compaction with events. * * @param allowDefer If true (default), threshold-driven handoff strategy is allowed to * schedule itself as a deferred post-prompt task and return a deferred-handoff result * immediately. The caller MUST treat that as "compaction will happen async — do not * also schedule `agent.continue()` for this turn", otherwise the deferred handoff * races a fresh streaming turn (the symptom: "Auto-handoff" loader + assistant * message still streaming). Callers on a path that is about to start a new agent * turn (e.g. the pre-prompt check in `#promptWithMessage`) pass `false` to force * inline execution so the handoff completes before the new turn begins. * @returns whether auto-compaction scheduled a follow-up turn. */ async runAutoCompaction( reason: "overflow" | "threshold" | "idle" | "incomplete", willRetry: boolean, deferred = false, allowDefer = true, options: { autoContinue?: boolean; triggerContextTokens?: number; suppressContinuation?: boolean; suppressHandoff?: boolean; phase?: CodexCompactionContext["phase"]; terminalTextAnswer?: boolean; } = {}, ): Promise { const compactionSettings = this.#host.settings.getGroup("compaction"); if (compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE; if (reason !== "idle" && !compactionSettings.enabled) return COMPACTION_CHECK_NONE; const generation = this.#host.promptGeneration(); const terminalTextAnswer = options.terminalTextAnswer ?? isTerminalTextAssistantAnswer(this.#host.findLastAssistantMessage()); const suppressContinuation = options.suppressContinuation === true; const shouldAutoContinue = !suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false; const suppressHandoff = options.suppressHandoff === true; let fallbackFromShake = false; // Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake // reclaims nothing we fall through to the summary-compaction body below so // the oversized input still gets resolved. if (compactionSettings.strategy === "shake") { const outcome = await this.#runAutoShake( reason, willRetry, generation, shouldAutoContinue, terminalTextAnswer, options.triggerContextTokens, suppressContinuation, ); if (outcome !== "fallback") return outcome; fallbackFromShake = true; } // "overflow" and "incomplete" force inline execution because they are recovery // paths the caller wants resolved before scheduling the next turn. "idle" is // triggered by the idle loop and does its own scheduling. if ( !suppressHandoff && !deferred && allowDefer && reason !== "overflow" && reason !== "incomplete" && reason !== "idle" && compactionSettings.strategy === "handoff" ) { this.#host.schedulePostPromptTask( async signal => { await Promise.resolve(); if (signal.aborted) return; await this.runAutoCompaction(reason, willRetry, true, true, { ...options, terminalTextAnswer, }); }, { generation }, ); return { ...COMPACTION_CHECK_DEFERRED_HANDOFF, continuationScheduled: shouldAutoContinue, }; } // "overflow" forces context-full because the input itself is broken — a handoff // LLM call would hit the same overflow. "incomplete" is an output-side problem, // so a handoff request on the existing context is still viable. let action: "context-full" | "handoff" | "snapcompact" = compactionSettings.strategy === "snapcompact" ? "snapcompact" : compactionSettings.strategy === "handoff" && reason !== "overflow" && !suppressHandoff ? "handoff" : "context-full"; if (action === "snapcompact" && this.#model && !this.#model.input.includes("image")) { this.#host.emitNotice( "warning", `snapcompact needs a vision-capable active model (${this.#model.id} is text-only); using context-full auto-compaction instead.`, "compaction", ); action = "context-full"; } // Abort any older auto-compaction before installing this run's controller. this.#autoCompactionAbortController?.abort(); const autoCompactionAbortController = new AbortController(); this.#autoCompactionAbortController = autoCompactionAbortController; const autoCompactionSignal = autoCompactionAbortController.signal; try { // Emit start AFTER the controller is installed so isCompacting is already true // for any listener — and for input routed during this emit's event-loop yield: // a message typed as the compaction loader appears must land in the compaction // queue, not the core steering queue (which handoff's agent.reset() would wipe). await this.#host.emitSessionEvent({ type: "auto_compaction_start", reason, action }); if (action === "handoff") { let handoffSwitchCancelled = false; const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS; const handoffResult = await this.#host.runHandoff(handoffFocus, { autoTriggered: true, signal: autoCompactionSignal, onSwitchCancelled: () => { handoffSwitchCancelled = true; }, }); if (!handoffResult) { const aborted = autoCompactionSignal.aborted || handoffSwitchCancelled; if (aborted) { await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }); return COMPACTION_CHECK_NONE; } logger.warn("Auto-handoff returned no document; falling back to context-full maintenance", { reason, }); action = "context-full"; } if (handoffResult) { await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, }); const continuationScheduled = !autoCompactionSignal.aborted && this.#host.scheduleCompactionContinuation({ generation, autoContinue: reason !== "idle" && shouldAutoContinue, terminalTextAnswer, suppressContinuation, }); return { ...(continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE), historyRewritten: true, }; } } if (!this.#model) { await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, skipped: true, }); return COMPACTION_CHECK_NONE; } const availableModels = this.#host.modelRegistry.getAvailable(); if (availableModels.length === 0) { await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, skipped: true, }); return COMPACTION_CHECK_NONE; } const pathEntries = this.#host.sessionManager.getBranch(); let pathEntriesForCompaction = pathEntries; let preparation = prepareCompaction(pathEntriesForCompaction, compactionSettings, this.#model); if (!preparation) { // prepareCompaction found nothing to summarize because the kept region // is a single oversized recent turn — findCutPoint never cuts inside a // tool result, so a huge tool-result / fenced block tail leaves nothing // on the summarizable side and summary compaction cannot even start. // That is exactly the dead-end the elide shake rescues: it reaches // INSIDE the tail and offloads heavy content to an artifact placeholder, // shrinking the tail so findCutPoint can then move the cut and leave // older turns to summarize. Run the same tiered rescue the // post-maintenance guard uses (elide, then image drop), with progress // defined as "prepareCompaction now succeeds on the rewritten branch", // and fall through to the normal compaction body when it does (writing // a compaction entry anchors the stale billed usage so the // auto-continue re-check cannot re-trip and loop the warning — issue // #4786). `skipElide` when we already fell through from a shake // strategy pass (it tried and found nothing); skip entirely on the // idle timer (it re-checks usage on its own cadence). let rescueRewroteHistory = false; // A snapcompact CompactionEntry is invisible to both rescue tiers // below (they only inspect message entries) and to prepareCompaction // itself (last-entry-is-compaction guard), so a frame archive billed // past the threshold dead-ends here on every resume. Rebuild it at a // threshold-derived frame budget first — but treat that as complete // only when it actually created headroom: the latest archive may not // be the oversized tail (e.g. a huge kept tool result after it), and // declaring victory on a mere frame-count shrink would skip the // elide/image tiers that can still reach that tail and suppress a // warning the user should see. let frameRescueResult: snapcompact.CompactionResult | undefined; let frameRescueCreatedHeadroom = false; if (reason !== "idle") { frameRescueResult = await this.#rescueSnapcompactFrameOverflow( pathEntriesForCompaction, compactionSettings, autoCompactionSignal, ); if (frameRescueResult) { rescueRewroteHistory = true; pathEntriesForCompaction = this.#host.sessionManager.getBranch(); frameRescueCreatedHeadroom = this.#compactionCreatedHeadroom(); } if (!frameRescueCreatedHeadroom) { await this.#rescueCompactionDeadEnd(autoCompactionSignal, { skipElide: fallbackFromShake, hasProgress: () => { // Only reached when a tier actually freed something, so the // branch has been rewritten either way. rescueRewroteHistory = true; pathEntriesForCompaction = this.#host.sessionManager.getBranch(); preparation = prepareCompaction(pathEntriesForCompaction, compactionSettings, this.#model); return preparation !== undefined; }, }); } } if (!preparation) { const noProgressDeadEnd = reason !== "idle" && !frameRescueCreatedHeadroom; const deadEndWarning = noProgressDeadEnd ? compactionDeadEndWarning("shrink it (e.g. clear large tool output)") : undefined; // A rescue that appended a rebuilt archive without creating // headroom must carry the dead-end badge on the entry the // transcript actually shows (the rebuilt one), or the pause // loses its explanation once the notice scrolls away. Stamp it // BEFORE the auto_compaction_end event: a result-carrying event // makes the TUI rebuild the chat from the current entries // immediately, so a later stamp would not appear until some // unrelated rebuild. if (deadEndWarning && frameRescueResult) { const stampEntry = getLatestCompactionEntry(this.#host.sessionManager.getBranch()); if (stampEntry) { stampEntry.warning = deadEndWarning; await this.#host.sessionManager.rewriteEntries(); } } // A successful frame rescue rewrote history and activated a new // compaction entry — surface it as a real (non-skipped) result so // the TUI rebuilds the transcript instead of treating the pass as // a benign no-op. await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result: frameRescueResult, aborted: false, willRetry: false, skipped: frameRescueResult === undefined, }); let continuationScheduled = false; if (frameRescueCreatedHeadroom) { continuationScheduled = this.#host.scheduleCompactionContinuation({ generation, autoContinue: shouldAutoContinue, terminalTextAnswer, suppressContinuation, }); } else if (!suppressContinuation && this.#host.agent.hasQueuedMessages()) { this.#host.scheduleAgentContinue({ delayMs: 100, generation, shouldContinue: () => this.#host.agent.hasQueuedMessages(), }); continuationScheduled = true; } if (deadEndWarning) { this.#host.emitNotice("warning", deadEndWarning, "compaction"); } // A rescue that offloaded content but still could not produce a // preparation rewrote the branch; flag it so the overflow-recovery // rollback does not re-restore the just-failed assistant turn on top // of the elided tail. const base = continuationScheduled ? COMPACTION_CHECK_CONTINUATION : noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE; return rescueRewroteHistory ? { ...base, historyRewritten: true } : base; } } let hookCompaction: CompactionResult | undefined; let fromExtension = false; let preserveData: Record | undefined; let codexCompaction: CodexCompactionContext | undefined; if (this.#host.extensionRunner?.hasHandlers("session_before_compact")) { const hookResult = (await this.#host.extensionRunner.emit({ type: "session_before_compact", preparation, branchEntries: pathEntriesForCompaction, customInstructions: undefined, signal: autoCompactionSignal, })) as SessionBeforeCompactResult | undefined; if (hookResult?.cancel) { await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }); return COMPACTION_CHECK_NONE; } if (hookResult?.compaction) { hookCompaction = hookResult.compaction; fromExtension = true; } } const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction); let summary: string; let shortSummary: string | undefined; let firstKeptEntryId: string; let tokensBefore: number; let details: unknown; // Snapcompact runs locally first. The post-compaction context = kept-recent // + a summary message carrying the imaged archive at FRAME_TOKEN_ESTIMATE // per frame; #computeSnapcompactMaxFrames sizes the frame cap from the // live window so we don't run snapcompact just to overflow every threshold // tick. Any local blocker (unsupported snapcompact glyphs, kept-history too large, // post-render overflow) downgrades auto maintenance to a context-full LLM // summary instead of wedging the session (#3659) — auto runs the default // strategy on the user's behalf, so a fallback that lets the session keep // running is the right behavior. Manual `/compact snapcompact` keeps the // local-only contract (#3599): the user explicitly picked it. let snapcompactResult: snapcompact.CompactionResult | undefined; let snapcompactBlocker: string | undefined; if (action === "snapcompact" && compactionPrep.kind !== "fromHook") { // Drop `¶think:` sections for Anthropic-dialect targets: the archive // is replayed as text and Claude refuses reproduced reasoning // ("reasoning_extraction", issue #6093). const snapcompactIncludeThinking = preferredDialect(this.#model.id) !== "anthropic"; const text = snapcompact.serializeConversation( convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)), { includeThinking: snapcompactIncludeThinking }, ); const probeText = snapcompact.renderabilityProbeText( text, preparation.previousPreserveData, preparation.previousSummary, ); const shapeSetting = this.#host.settings.get("snapcompact.shape"); const shape = snapcompact.resolveShapeForText(probeText, this.#model, shapeSetting); const renderScan = snapcompact.scanRenderability(probeText, { shape }); if (!renderScan.isSafe) { const percent = (renderScan.unrenderableRatio * 100).toFixed(1); logger.warn("Snapcompact disabled: unsupported characters for selected snapcompact font", { model: this.#model?.id, unrenderableRatio: renderScan.unrenderableRatio, }); snapcompactBlocker = `snapcompact disabled: unsupported characters for selected snapcompact font (${percent}%); using context-full auto-compaction instead.`; } else { const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings); if (maxFrames < 1) { logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", { model: this.#model?.id, }); snapcompactBlocker = "snapcompact: kept history alone exceeds the context budget; using context-full auto-compaction instead."; } else { snapcompactResult = await snapcompact.compact(preparation, { convertToLlm, model: this.#model, ...(shapeSetting === "auto" ? {} : { shape }), maxFrames, includeThinking: snapcompactIncludeThinking, }); const framePayloadBytes = this.#snapcompactFramePayloadBytes(snapcompactResult); if (framePayloadBytes > snapcompact.FRAME_DATA_BYTES_BUDGET) { logger.warn("Snapcompact exceeded the per-request frame payload budget", { model: this.#model?.id, framePayloadBytes, budget: snapcompact.FRAME_DATA_BYTES_BUDGET, }); snapcompactBlocker = "snapcompact produced too much standing image payload; using context-full auto-compaction instead."; snapcompactResult = undefined; } if (snapcompactResult) { const ctxWindow = this.#model?.contextWindow ?? 0; const budget = ctxWindow > 0 ? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings) : Number.POSITIVE_INFINITY; const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult); if (projected > budget) { logger.warn("Snapcompact still overflows the window after frame-budget sizing", { model: this.#model?.id, projected, budget, }); snapcompactBlocker = "snapcompact could not bring the context under the limit; using context-full auto-compaction instead."; snapcompactResult = undefined; } } } } if (snapcompactBlocker) { this.#host.emitNotice("warning", snapcompactBlocker, "compaction"); action = "context-full"; } } if (compactionPrep.kind === "fromHook") { summary = compactionPrep.summary; shortSummary = compactionPrep.shortSummary; firstKeptEntryId = compactionPrep.firstKeptEntryId; tokensBefore = compactionPrep.tokensBefore; details = compactionPrep.details; preserveData = compactionPrep.preserveData; } else if (snapcompactResult) { summary = snapcompactResult.summary; shortSummary = snapcompactResult.shortSummary; firstKeptEntryId = snapcompactResult.firstKeptEntryId; tokensBefore = snapcompactResult.tokensBefore; details = snapcompactResult.details; preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) }; } else { const candidates = this.#getCompactionModelCandidates(availableModels); const retrySettings = this.#host.settings.getGroup("retry"); const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId()); let compactResult: CompactionResult | undefined; let lastError: unknown; let nativeCompactionFailure: { error: NativeCompactionError; provider: string } | undefined; codexCompaction = createCodexCompactionContext({ trigger: "auto", reason: "context_limit", phase: options.phase ?? (reason === "threshold" ? "pre_turn" : reason === "idle" ? "standalone_turn" : "mid_turn"), }); for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) { const candidate = candidates[candidateIndex]; const hasMoreCandidates = candidateIndex < candidates.length - 1; const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId()); if (!apiKey) continue; if ( nativeCompactionFailure && (candidate.provider !== nativeCompactionFailure.provider || !shouldUseProviderNativeCompaction(candidate, preparation.settings)) ) { throw nativeCompactionFailure.error; } let attempt = 0; while (true) { try { compactResult = await compact( this.#host.obfuscatePreparationForProvider(preparation), candidate, this.#host.modelRegistry.resolver(candidate, this.#host.sessionId()), undefined, autoCompactionSignal, { promptOverride: this.#host.obfuscateTextForProvider(compactionPrep.hookPrompt), extraContext: compactionPrep.hookContext, remoteInstructions: this.#host.baseSystemPrompt().join("\n\n"), metadata: this.#host.agent.metadataForProvider(candidate.provider), initiatorOverride: "agent", convertToLlm: messages => this.#host.convertToLlmForSideRequest(messages), telemetry, // Honor the user's /model thinking selection on the // auto-compaction path — the most-fired compaction // site. Clamped per-model inside compact() via // resolveCompactionEffort. thinkingLevel: this.#host.thinkingLevel(), tools: this.#host.agent.state.tools, sessionId: this.#host.sessionId(), promptCacheKey: this.#host.sessionId(), providerSessionState: this.#host.providerSessionState, codexCompaction, }, ); break; } catch (error) { if (autoCompactionSignal.aborted) { throw error; } const message = error instanceof Error ? error.message : String(error); const id = AIError.classify( error instanceof NativeCompactionError ? error.cause : error, candidate.api, ); if (AIError.is(id, AIError.Flag.AuthFailed)) { if (!nativeCompactionFailure) lastError = this.#buildCompactionAuthError(); break; } if (AIError.is(id, AIError.Flag.Timeout)) { const nativeFailure = error instanceof NativeCompactionError; logger.warn( nativeFailure ? "Provider-native auto-compaction timed out, preserving native failure" : hasMoreCandidates ? "Auto-compaction summarization timed out, trying next model" : "Auto-compaction summarization timed out, not retrying same model", { error: message, model: `${candidate.provider}/${candidate.id}`, }, ); if (nativeFailure) { nativeCompactionFailure ??= { error, provider: candidate.provider }; lastError = nativeCompactionFailure.error; } else { lastError = error; } break; } const retryAfterMs = this.#host.parseRetryAfterMsFromError(message); const shouldRetry = retrySettings.enabled && attempt < retrySettings.maxRetries && (retryAfterMs !== undefined || AIError.is(id, AIError.Flag.Transient) || AIError.is(id, AIError.Flag.UsageLimit)); if (!shouldRetry) { if (error instanceof NativeCompactionError) { nativeCompactionFailure ??= { error, provider: candidate.provider }; lastError = nativeCompactionFailure.error; } else { lastError = error; } break; } const baseDelayMs = retrySettings.baseDelayMs * 2 ** attempt; const delayMs = retryAfterMs !== undefined ? Math.max(baseDelayMs, retryAfterMs) : baseDelayMs; // If retry delay is too long (>30s), try next candidate instead of waiting const maxAcceptableDelayMs = 30_000; if (delayMs > maxAcceptableDelayMs && hasMoreCandidates) { if (error instanceof NativeCompactionError) { nativeCompactionFailure ??= { error, provider: candidate.provider }; lastError = nativeCompactionFailure.error; break; } logger.warn("Auto-compaction retry delay too long, trying next model", { delayMs, retryAfterMs, error: message, model: `${candidate.provider}/${candidate.id}`, }); lastError = error; break; // Exit retry loop, continue to next candidate } attempt++; logger.warn("Auto-compaction failed, retrying", { attempt, maxRetries: retrySettings.maxRetries, delayMs, retryAfterMs, error: message, model: `${candidate.provider}/${candidate.id}`, }); await scheduler.wait(delayMs, { signal: autoCompactionSignal }); } } if (compactResult) { break; } } if (!compactResult) { if (lastError) { throw lastError; } throw new Error("Compaction failed: no available model"); } summary = compactResult.summary; shortSummary = compactResult.shortSummary; firstKeptEntryId = compactResult.firstKeptEntryId; tokensBefore = compactResult.tokensBefore; details = compactResult.details; preserveData = mergeLlmCompactionPreserveData(compactionPrep.preserveData, compactResult.preserveData); } if (autoCompactionSignal.aborted) { await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }); return COMPACTION_CHECK_NONE; } this.#host.sessionManager.appendCompaction( summary, shortSummary, firstKeptEntryId, tokensBefore, details, fromExtension, preserveData, ); const newEntries = this.#host.sessionManager.getEntries(); const sessionContext = this.#host.buildDisplaySessionContext(); this.#host.agent.replaceMessages(sessionContext.messages); this.#host.rebaseAfterCompaction(); // Compaction discarded the conversation history that carried the approved // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads // the plan from disk and re-injects it on the next turn (issue #1246). this.#host.resetPlanReference(); this.#host.resetAdvisorRuntimes(); this.#host.syncTodoPhasesFromBranch(); if (codexCompaction) { this.#host.resetCodexProviderAfterCompaction(codexCompaction); } else { this.#host.closeCodexProviderSessionsForHistoryRewrite(); } // Get the saved compaction entry for the hook const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as | CompactionEntry | undefined; if (this.#host.extensionRunner && savedCompactionEntry) { await this.#host.extensionRunner.emit({ type: "session_compact", compactionEntry: savedCompactionEntry, fromExtension, }); } const result: CompactionResult = { summary, shortSummary, firstKeptEntryId, tokensBefore, details, preserveData, }; // Post-maintenance progress guard — evaluated BEFORE emitting // auto_compaction_end so the TUI rebuild triggered by that event // already reflects any rescue rewrite (elide / image-drop) and the // dead-end warning stamped on the compaction entry. Snapcompact can // project over budget and fall back to a context-full summary; the // summarizer keeps `keepRecentTokens` of recent history verbatim and // findCutPoint can only cut at turn boundaries (never tool results), // so a single oversized recent turn (e.g. a huge tool result) leaves // the rewritten context still above threshold. Scheduling the // continuation regardless means the next agent_end re-enters // checkCompaction over the same oversized tail and re-fires forever. // The retry and the threshold auto-continue use different progress // tests (a recoverable overflow only has to fit; the auto-continue // thrash needs the stricter recovery band), so each branch evaluates // its own below. let continuationScheduled = false; // A non-idle pass that wanted to continue (retry or auto-continue) but freed // too little for that path to proceed is a dead-end: warn once so the user // understands why maintenance paused instead of silently looping. let noProgressDeadEnd = false; let retryFits = false; let hasHeadroom = false; if (willRetry) { const messages = this.#host.agent.state.messages; const lastMsg = messages[messages.length - 1]; if (lastMsg?.role === "assistant") { const lastAssistant = lastMsg as AssistantMessage; // Drop the prior turn before retry when it carries no actionable deliverable: // - "error": failure was kept in history but must not re-enter the next turn's prompt. // - reason === "incomplete" && stopReason === "length": truncated output (typically // reasoning-only) — re-running it produces the same dead-end. const shouldDrop = lastAssistant.stopReason === "error" || (reason === "incomplete" && lastAssistant.stopReason === "length"); if (shouldDrop) { this.#host.agent.replaceMessages(messages.slice(0, -1)); this.#host.rebaseAfterCompaction(); } } // Retry only needs the rebuilt prompt to fit the window again — measured // AFTER the drop above so the just-failed turn (which the retry prompt // won't include) is excluded. Reusing the auto-continue recovery band // here turned recoverable overflows into manual dead-ends (#3412 review), // so use the looser fit budget. retryFits = this.#compactionCreatedRetryFit(); if (!retryFits) { retryFits = await this.#rescueCompactionDeadEnd(autoCompactionSignal, { skipElide: fallbackFromShake, hasProgress: () => this.#compactionCreatedRetryFit(), }); } if (!retryFits) { noProgressDeadEnd = true; } } else if (reason !== "idle") { // Mirror the shake recovery-band check: only auto-continue when compaction // landed residual context under `COMPACTION_RECOVERY_BAND × threshold`. // Re-firing on a history that still sits just over the line is the // snapcompact thrash, so require genuine headroom, not a bare fit. Even // when auto-continue is disabled, a no-headroom threshold pass must still // block later automatic continuations (todo reminders/session_stop hooks) // from re-entering the same oversized context. hasHeadroom = this.#compactionCreatedHeadroom(); if (!hasHeadroom) { hasHeadroom = await this.#rescueCompactionDeadEnd(autoCompactionSignal, { skipElide: fallbackFromShake, hasProgress: () => this.#compactionCreatedHeadroom(), }); } if (!hasHeadroom) { noProgressDeadEnd = true; } } const deadEndWarning = noProgressDeadEnd ? compactionDeadEndWarning("clear large tool output") : undefined; if (deadEndWarning) { // Stamp the divider: the compaction bar badges the dead-end and // carries the full warning in its ctrl+o detail, so the pause // stays explained even after the notice row scrolls away. Stamp // the branch's LATEST compaction entry — a frame rescue may have // superseded `savedCompactionEntry` with a rebuilt one, and the // collapsed transcript badges only the active entry. const stampEntry = getLatestCompactionEntry(this.#host.sessionManager.getBranch()) ?? savedCompactionEntry; if (stampEntry) { stampEntry.warning = deadEndWarning; await this.#host.sessionManager.rewriteEntries(); } } await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry }); if (retryFits) { this.#host.scheduleAgentContinue({ delayMs: 100, generation }); continuationScheduled = true; } else { continuationScheduled = this.#host.scheduleCompactionContinuation({ generation, autoContinue: hasHeadroom && shouldAutoContinue, terminalTextAnswer, suppressContinuation, }); } if (deadEndWarning) { this.#host.emitNotice("warning", deadEndWarning, "compaction"); } if (continuationScheduled) return COMPACTION_CHECK_CONTINUATION; return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE; } catch (error) { if (autoCompactionSignal.aborted) { await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }); return COMPACTION_CHECK_NONE; } const errorMessage = error instanceof Error ? error.message : "compaction failed"; await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, errorMessage: reason === "overflow" ? `Context overflow recovery failed: ${errorMessage}` : reason === "incomplete" ? `Incomplete response recovery failed: ${errorMessage}` : `Auto-compaction failed: ${errorMessage}`, }); } finally { if (this.#autoCompactionAbortController === autoCompactionAbortController) { this.#autoCompactionAbortController = undefined; } } return COMPACTION_CHECK_NONE; } /** * Run a shake-strategy auto-maintenance pass. Emits the * `auto_compaction_start`/`auto_compaction_end` pair with a shake `action`, * runs {@link shake} inline against the protect-window config, and schedules * continuation exactly like the context-full tail. * * Returns `"fallback"` only for an overflow recovery where shake reclaimed * nothing (or threw) — the caller then runs the summary-compaction body so * the oversized input still gets resolved. Returns `"handled"` otherwise. */ async #runAutoShake( reason: "overflow" | "threshold" | "idle" | "incomplete", willRetry: boolean, generation: number, autoContinue: boolean, terminalTextAnswer: boolean, triggerContextTokens?: number, suppressContinuation = false, ): Promise { const action = "shake"; this.#autoCompactionAbortController?.abort(); const controller = new AbortController(); this.#autoCompactionAbortController = controller; const signal = controller.signal; try { await this.#host.emitSessionEvent({ type: "auto_compaction_start", reason, action }); const result = await this.#host.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal }); if (signal.aborted) { await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }); return COMPACTION_CHECK_NONE; } const reclaimed = result.toolResultsDropped + result.blocksDropped > 0; // Detect the dead-loop reported in issues #2119/#2275: the threshold check // fires, shake runs, but residual context is still above the configured // threshold. The next agent_end would re-trigger shake, which has nothing // new to drop on the second pass, so the loop spins until the user kills it. // Same hazard for "incomplete" (the retry would re-hit the length cap) and // for the existing "overflow + nothing reclaimed" case. In every recovery // reason we hand off to the summarization-driven context-full path so the // situation actually resolves; "idle" is exempt because its 60s+ timer // re-checks usage before re-firing and cannot dead-loop on its own. // // #2275: the post-shake check MUST stay provider-anchored when caller // usage and local estimates diverge. The local estimator undercounts // thinking-signature payloads, so thinking-heavy sessions can read well // below the provider usage that fired the threshold. Prefer the caller's // context figure when supplied, then subtract shake's own savings and add // hysteresis (80% recovery band) so we don't oscillate at the boundary. // Threshold callers pass the provider-billed trigger after accounting for // any supersede/drop-useless pruning that already rewrote the next prompt; // without that pre-shake savings, shake can fall through to context-full // even though the post-prune history is already inside the recovery band. const contextWindow = this.#model?.contextWindow ?? 0; const compactionSettings = this.#host.settings.getGroup("compaction"); let stillOverThreshold = false; if (contextWindow > 0) { if (typeof triggerContextTokens === "number" && Number.isFinite(triggerContextTokens)) { const correctedTokens = Math.max(0, triggerContextTokens - result.tokensFreed); const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings); const recoveryBand = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND); stillOverThreshold = correctedTokens > recoveryBand; } else { const postShakeTokens = this.#host.getContextUsage({ contextWindow })?.tokens ?? 0; stillOverThreshold = shouldCompact(postShakeTokens, contextWindow, compactionSettings); } } const shouldFallBack = reason !== "idle" && ((reason === "overflow" && !reclaimed) || stillOverThreshold); if (shouldFallBack) { const errorMessage = reclaimed ? `Auto-shake reclaimed ~${result.tokensFreed} tokens but context is still above the threshold; falling back to context-full compaction.` : "Auto-shake found nothing eligible to drop; falling back to context-full compaction."; await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, skipped: !reclaimed, errorMessage, }); return "fallback"; } await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry, skipped: !reclaimed, }); let continuationScheduled = false; if (willRetry) { // The shake rebuild replays every entry, so a trailing error/length // assistant from the failed turn re-enters agent state — drop it before // retrying, same as the context-full tail. const messages = this.#host.agent.state.messages; const lastMsg = messages[messages.length - 1]; if (lastMsg?.role === "assistant") { const lastAssistant = lastMsg as AssistantMessage; const shouldDrop = lastAssistant.stopReason === "error" || (reason === "incomplete" && lastAssistant.stopReason === "length"); if (shouldDrop) this.#host.agent.replaceMessages(messages.slice(0, -1)); } this.#host.scheduleAgentContinue({ delayMs: 100, generation }); continuationScheduled = true; } else { continuationScheduled = this.#host.scheduleCompactionContinuation({ generation, autoContinue: reason !== "idle" && autoContinue, terminalTextAnswer, suppressContinuation, }); } if (!reclaimed) { return willRetry && continuationScheduled ? { ...COMPACTION_CHECK_CONTINUATION, historyRewritten: true } : continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE; } return { ...(continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE), historyRewritten: true, }; } catch (error) { if (signal.aborted) { await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }); return COMPACTION_CHECK_NONE; } const message = error instanceof Error ? error.message : "shake failed"; await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, errorMessage: message, skipped: false, }); // Overflow still needs recovery even if shake threw. return reason === "overflow" ? "fallback" : COMPACTION_CHECK_NONE; } finally { if (this.#autoCompactionAbortController === controller) { this.#autoCompactionAbortController = undefined; } } } /** * Toggle auto-compaction setting. */ setAutoCompactionEnabled(enabled: boolean): void { this.#host.settings.set("compaction.enabled", enabled); if (enabled && this.#host.settings.get("compaction.strategy") === "off") { const defaultStrategy = getDefault("compaction.strategy"); this.#host.settings.set("compaction.strategy", defaultStrategy === "off" ? "context-full" : defaultStrategy); } } /** Whether auto-compaction is enabled */ get autoCompactionEnabled(): boolean { return this.#host.settings.get("compaction.enabled") && this.#host.settings.get("compaction.strategy") !== "off"; } }