/** Context maintenance for an active coding-agent session. */ import { scheduler } from "node:timers/promises"; import { type Agent, type AgentMessage, type AgentTurnEndContext, type MessageCountOptions, resolveTelemetry, type StreamFn, type ThinkingLevel, Tokenizer, } from "@oh-my-pi/pi-agent-core"; import { AGGRESSIVE_SHAKE_CONFIG, AUTO_HANDOFF_THRESHOLD_FOCUS, applyShakeRegions, CompactionCancelledError, type CompactionDetails, type CompactionPreparation, type CompactionResult, type CompactionSummaryMessage, calculateContextTokens, canReplayRemoteCompactionFrom, collectShakeRegions, COMPACTION_RECOVERY_BAND, compact, compactionContextTokens, countResponsesHistoryTokens, createCompactionSummaryMessage, DEFAULT_SHAKE_CONFIG, type CompactionSettings as EngineCompactionSettings, effectiveReserveTokens, invalidateMessageCache, isTranscriptUsageAnchor, NativeCompactionError, prepareCompaction, promptBudgetTokens, RESCUE_SHAKE_CONFIG, remoteCompactionPolicyModel, remotePreserveReusable, resolveBudgetReserveTokens, resolveThresholdTokens, type ShakeConfig, type ShakeRegion, type SummaryOptions, shouldCompact, shouldUseProviderNativeCompaction, } from "@oh-my-pi/pi-agent-core/compaction"; import { DEFAULT_PRUNE_CONFIG, type PruneResult, 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, Context, Message, Model, ProviderSessionState, } from "@oh-my-pi/pi-ai"; import * as AIError from "@oh-my-pi/pi-ai/error"; import { replaysNativeResponsesHistory } from "@oh-my-pi/pi-ai/providers/openai-shared"; import { openAIResponsesReplaysNativeHistory } from "@oh-my-pi/pi-ai/providers/openai-responses-state"; import { sendsImageInputOnWire } from "@oh-my-pi/pi-ai/providers/vision-guard"; import { resolvePromptCacheLookback } from "@oh-my-pi/pi-catalog/compat/prompt-cache-lookback"; import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import type { CatalogPolicyInputs } from "@oh-my-pi/pi-catalog/types"; import { isRecord, logger, prompt, Snowflake, toError } from "@oh-my-pi/pi-utils"; import * as snapcompact from "@oh-my-pi/snapcompact"; import { writeArtifact } from "./artifacts"; import type { ModelRegistry } from "../config/model-registry"; import { CHAT_MODEL_ROLE_IDS } from "../config/model-roles"; import type { Settings } 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 { customMessageEntryMessage } from "@oh-my-pi/pi-tui/chat/transcript-entry"; import { computeNonMessageTokens, type NonMessageTokenSource } from "@oh-my-pi/pi-tui/status-line/context-usage"; import { createPlanReadMatcher } from "../plan-mode/plan-protection"; import { isCompleteReadResult } from "../tools/read-supersede"; import type { ConfiguredThinkingLevel } from "@oh-my-pi/pi-tui/thinking"; import type { AgentSessionEvent } from "./agent-session-events"; import type { ContextUsageBreakdown, HandoffResult, SessionHandoffOptions } from "./agent-session-types"; import { findCompactMode } from "./compact-modes"; import { type CompactionMethod, canUseRemoteCompaction, DEFAULT_COMPACTION_METHOD_ORDER, resolveCompactionMethodOrder, resolveMethodSettings, resolveSpeculationMethod, } from "./compaction-methods"; import { assistantTurnDelivered, convertToLlm, invalidateConvertToLlmArrayCache, isUserAuthoredMessage, stripImagesFromMessage, } from "./messages"; import { isTerminalTextAssistantAnswer } from "./queued-messages"; import { resolveCompactionConfiguredTarget, resolveContextPromotionConfiguredTarget, resolveRoleModelFull, } from "./role-models"; import type { BuildSessionContextOptions, SessionContext } from "./session-context"; import { buildSessionContext, getLatestCompactionEntry, getOpenAiRemoteCompactionPayload, getReadableCompactionEntry, } from "./session-context"; import type { CompactionEntry, SessionEntry } from "./session-entries"; import type { SessionManager } from "./session-manager"; import type { ShakeMode, ShakeResult } from "./shake-types"; import { resolveSpeculationLeadTokens, SPECULATION_LEAD_MIN_TOKENS } from "./speculation-lead"; import experimentalContextNotesReminderPrompt from "../prompts/system/experimental-context-notes-reminder.md" with { type: "text" }; import experimentalContextRolloverPrompt from "../prompts/system/experimental-context-rollover.md" with { type: "text" }; import lengthStopRetryTemplate from "../prompts/system/length-stop-retry.md" with { type: "text" }; import { type CompactionSettings, cfgCompactionAutoContinue, cfgCompactionEnabled, cfgCompactionMethodOrder, cfgContextPromotionEnabled, cfgSnapcompactShape, } from "./context-settings"; import { resolveModelCompactionSettings } from "./model-compaction-threshold"; import { cfgProvidersOpenaiCodexDaybreak, cfgRetry } from "./settings"; export type CompactionCheckResult = Readonly<{ continuationScheduled: boolean; automaticContinuationBlocked?: boolean; historyRewritten?: boolean; }>; /** Shared no-op result for dispatcher paths that perform no maintenance. */ export const COMPACTION_CHECK_NONE: CompactionCheckResult = { continuationScheduled: false, }; const COMPACTION_CHECK_CONTINUATION: CompactionCheckResult = { continuationScheduled: true, }; const COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION: CompactionCheckResult = { continuationScheduled: false, automaticContinuationBlocked: true, }; const FIXED_CONTEXT_DEAD_END_WARNING = "Compaction cannot create headroom — pausing automatic maintenance. " + "The system prompt and always-sent tool schemas alone exceed the recovery band; " + "reduce fixed context or adjust compaction.modelThresholds."; /** * Consecutive `response.incomplete` (length-stop) recoveries that deliver no * text or tool call before recovery gives up. A model that keeps returning an * empty or reasoning-only `length` turn (seen with `zai/glm-4.5-flash`, #10594) * would otherwise retry forever, persisting a dead assistant turn on every * attempt. Mirrors the empty-stop / unexpected-stop retry caps in * {@link TurnRecovery}; any delivered turn ({@link assistantTurnDelivered}) * resets the counter, so legitimate multi-step recoveries are never cut short. */ export const INCOMPLETE_RECOVERY_MAX_RETRIES = 3; /** Whether a configured preference list contains at least one automatic method. */ function hasConfiguredCompactionMethod(settings: CompactionSettings): boolean { return resolveCompactionMethodOrder(settings.methodOrder).length > 0; } /** Kept Claude thinking (Anthropic Messages or Bedrock Converse) requires compaction by the same live model. */ function canUseLiveProviderNativeCompaction( candidate: Model, liveModel: Model, settings: EngineCompactionSettings, ): boolean { const requiresSameModel = candidate.api === "anthropic-messages" || candidate.api === "bedrock-converse-stream"; return ( candidate.provider === liveModel.provider && (!requiresSameModel || (candidate.api === liveModel.api && candidate.id === liveModel.id)) && shouldUseProviderNativeCompaction(candidate, settings) ); } /** * Whether `candidate` is actually selectable for `reason` on `model` — mirrors the * per-candidate availability check in {@link SessionMaintenance.runAutoCompaction}'s * method-order loop so every caller agrees with what would really be selected. * * `excludeMedia` skips `snapcompact` regardless of model support: it archives * history onto base64-encoded image frames (up to ~3 MB), which is the opposite * of what a byte/payload-limit 413 recovery needs — a request already rejected * for being too large in bytes should not be retried with an even larger, * media-heavy one (#11482). */ function isCompactionMethodUsable( candidate: CompactionMethod, reason: "overflow" | "threshold" | "idle" | "incomplete", model: Model | undefined, settings: CompactionSettings, excludeMedia = false, ): boolean { return candidate === "remote" ? canUseRemoteCompaction(model, resolveMethodSettings(settings, candidate)) : candidate === "snapcompact" ? !excludeMedia && model !== undefined && sendsImageInputOnWire(model) : candidate === "handoff" ? reason !== "overflow" : true; } /** * Whether the configured method order contains at least one method that * `runAutoCompaction` would actually select for `reason` on `model` — a non-empty * `methodOrder` alone (see {@link hasConfiguredCompactionMethod}) is not enough: an * unusable-for-this-reason configuration (e.g. `methodOrder: ["handoff"]` for an * `"overflow"` reason, or `snapcompact`-only on a text-only model) would otherwise be * reported as available and then silently no-op in `runAutoCompaction` (#11482). */ function hasUsableCompactionMethod( reason: "overflow" | "threshold" | "idle" | "incomplete", model: Model | undefined, settings: CompactionSettings, excludeMedia = false, ): boolean { return resolveCompactionMethodOrder(settings.methodOrder).some(candidate => isCompactionMethodUsable(candidate, reason, model, settings, excludeMedia), ); } /** * Concrete media-limit wording in a payload-rejection error (e.g. * `request_too_large: too many images`, `image count exceeds the limit of * 20`). `ambiguousPayloadRejection` (dual `ContextOverflow` + `PayloadRejected`) * only catches text that *also* matches a generic numeric-limit pattern — a * digit-free media rejection like the first example sails through as * non-ambiguous. That text is still definitive media-budget evidence: token * compaction can't raise a provider's image-count limit, and some methods * (snapcompact) *add* image frames, making it worse. Such rejections must * stay on the terminal payload-dead-end path regardless of compaction * availability (#11482). * * Deliberately narrower than matching bare "images"/"media"/"vision"/"frames"/ * "pixels" anywhere in the text: a custom provider's error that merely names a * vision model (`llava-vision`) or an unrelated `Content-Type` (`media type * application/json`) is not evidence the *request* was rejected for a media * budget — treating it as such would permanently dead-end an unknown-window * session that ordinary compaction could actually recover (#11482). Require * the noun to co-occur with a count/limit signal instead — covering the * common phrasings: "too many images", "image count"/"image limit", "limit * of N images", "maximum (of N) images", "number/count of images", * "images exceeds ... maximum", and per-image size/dimension rejections * like "image is too large" or "image dimensions exceed 8000 pixels" * (#11482). */ const PAYLOAD_MEDIA_LIMIT_EVIDENCE_PATTERN = /\btoo many (?:images?|frames?|pixels?)\b|\b(?:images?|frames?|pixels?)\s*(?:count|limit)\b|\blimit of \d+\s*(?:images?|frames?|pixels?)\b|\bmaximum(?: of \d+)? (?:images?|frames?|pixels?)\b|\b(?:number|count) of (?:images?|frames?|pixels?)\b|\b(?:images?|frames?|pixels?)\b.{0,20}\bexceeds?\b.{0,20}\bmaximum\b|\b(?:images?|frames?) (?:is |are )?too large\b|\b(?:images?|frames?) dimensions?\b.{0,30}\bexceeds?\b.{0,30}\b(?:pixels?|\d+)\b/i; function hasExplicitMediaRejectionEvidence(errorMessage: string | undefined): boolean { return errorMessage !== undefined && PAYLOAD_MEDIA_LIMIT_EVIDENCE_PATTERN.test(errorMessage); } /** * Newest usage-anchor entry strictly after `compactionIndex`, or -1 when the * branch carries none. Shared by the image, thinking and tool-result shake * passes so all three subtract pre-anchor savings on the same basis. */ function findUsageAnchorIndex(branchEntries: readonly SessionEntry[], compactionIndex: number): number { for (let index = branchEntries.length - 1; index > compactionIndex; index--) { const entry = branchEntries[index]; if (entry?.type !== "message" || !isTranscriptUsageAnchor(entry.message)) continue; return index; } return -1; } /** A terminal payload rejection may be byte-sized history or irreducible media, not necessarily token overflow. */ function payloadRejectionNotice(storedTokens: number, contextWindow: number): string { const remedies = "Reduce or remove oversized media from the live request, shrink older history, or raise the provider/proxy body limit."; if (contextWindow <= 0) { return `The provider rejected the request size or media budget (HTTP 413), and this model has no known context window to compare against — this is NOT necessarily a token-context problem. ${remedies}`; } const headroom = Math.max(0, Math.floor(contextWindow - storedTokens)); return `The provider rejected the request size or media budget (HTTP 413), but ~${headroom.toLocaleString("en-US")} tokens of headroom remain locally — this is NOT necessarily a token-context problem. ${remedies}`; } /** Dead-end notice when provider-reported usage proves context overflow but no recovery exists (#9235). */ function usageOverflowDeadEndNotice(reportedInputTokens: number, contextWindow: number): string { const windowLabel = contextWindow > 0 ? contextWindow.toLocaleString("en-US") : "unknown"; return ( `The provider reports ~${reportedInputTokens.toLocaleString("en-US")} input tokens against a ${windowLabel}-token context window — this IS a token-context problem, but automatic compaction is unavailable to shrink it. ` + "Enable a compaction method (compaction.enabled with a configured methodOrder), reduce the conversation's token usage, 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; /** Share of the room under the compaction trigger a snapcompact archive may fill; the rest is left for new turns. */ const SNAPCOMPACT_ARCHIVE_SHARE = 0.5; /** * Sizing target for a snapcompact-compacted context, as a share of the * compaction trigger: the archive budget never plans past it, and a render * that lands above it is re-rendered with fewer frames. */ const SNAPCOMPACT_POST_COMPACTION_TARGET = 0.6; /** Payload-shaped 413s share text patterns with overflow; trust the payload classification when local * occupancy stays under this ceiling (≥10% headroom) and reported usage stays within the window (#9235). */ const PAYLOAD_REJECTION_OCCUPANCY_CEILING = 0.9; /** A speculation-produced compaction result, ready to commit at threshold. */ interface ArmedSpeculation { result: CompactionResult; action: "context-full" | "handoff" | "remote"; method: CompactionMethod; codexCompaction?: CodexCompactionContext; /** Last branch entry covered by the speculated summary's source snapshot. */ snapshotLeafId: string; /** Context size when speculation started; drives refresh-on-growth. */ contextTokensAtStart: number; } type CompactionProjectionArgs = { summary: string; shortSummary?: string; tokensBefore: number; firstKeptEntryId: string; preserveData?: Record; details?: unknown; method?: CompactionMethod; providerReplayThroughEntryId?: string; }; /** One background speculative-compaction run and (once resolved) its armed result. */ interface SpeculationRun { controller: AbortController; promise: Promise; contextTokensAtStart: number; armed?: ArmedSpeculation; } function mergeLlmCompactionPreserveData( hookPreserveData: Record | undefined, resultPreserveData: Record | undefined, ): Record | undefined { const preserveData = { ...hookPreserveData, ...resultPreserveData }; return snapcompact.stripPreservedArchive(Object.keys(preserveData).length > 0 ? preserveData : undefined); } /** Wrap a handoff document as a compaction summary: append the cumulative file-operations tag and derive entry details. */ function handoffSummaryFromDocument( document: string, preparation: CompactionPreparation, ): { summary: string; details: CompactionDetails } { const { readFiles, modifiedFiles } = snapcompact.computeFileLists(preparation.fileOps); return { summary: snapcompact.upsertFileOperations(document, readFiles, modifiedFiles, preparation.fileOps.read), details: { readFiles, modifiedFiles }, }; } /** * Manual compaction rejected as a no-op: the session is too small, or its * branch already ends in a compaction entry. Thrown before this pass runs a * method or appends anything, so — unlike a hook cancel or a summarizer * failure — a turn the pass aborted (or a resume it inherited from an earlier * pass) is resumed exactly as after a committed summary. */ class ManualCompactionNoOpError extends Error { constructor(message: string) { super(message); this.name = "ManualCompactionNoOpError"; } } /** Capabilities borrowed from the owning AgentSession. */ export interface SessionMaintenanceHost { agent: Agent; sessionManager: SessionManager; settings: Settings; modelRegistry: ModelRegistry; extensionRunner: ExtensionRunner | undefined; sideStreamFn: StreamFn; providerSessionState: Map; /** Codex-only request preference from the live session setting. */ preferWebsockets(model: Model): boolean | undefined; model(): Model | undefined; thinkingLevel(): ThinkingLevel | undefined; isDisposed(): boolean; /** Model fallback is disabled (a per-call `model` subagent): no context promotion. */ modelFallbackDisabled?(): boolean; isStreaming(): boolean; isGeneratingHandoff(): boolean; promptGeneration(): number; sessionId(): string; messages(): AgentMessage[]; baseSystemPrompt(): string[]; goalModeState(): GoalModeState | undefined; planReferencePath(): string; nonMessageTokenSource(): NonMessageTokenSource; hasExperimentalContextRolloverTools(): boolean; takeExperimentalContextRolloverRequest(context: AgentTurnEndContext | undefined): boolean; queueExperimentalContextNotesReminder(prompt: string): void; memoryBackendSession(): MemoryBackendOperationContext["session"]; emitSessionEvent(event: AgentSessionEvent, options?: { detachExtensions?: boolean }): Promise; emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void; scheduleAgentContinue(options: { source: string; 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; /** The stored context as `model` reads it (the active model when omitted). */ buildDisplaySessionContext(model?: Model): SessionContext; /** Adopt a {@link buildDisplaySessionContext} result as the live history, persisting any snapshot it appended. */ replaceMessagesFromSessionContext(messages: AgentMessage[]): void; /** Preserve the eval runtime snapshot at the history rewrite boundary for replay. */ persistEvalStateContext(): void; convertToLlmForSideRequest(messages: AgentMessage[]): Message[]; /** The provider context a live turn sends for `summarized` + `retained`, cut to the `summarized` range, as `model` reads it. */ buildLiveProviderContext( summarized: AgentMessage[], retained: AgentMessage[], signal: AbortSignal | undefined, model: Model, ): Promise; obfuscateTextForProvider(text: string | undefined): string | undefined; obfuscatePreparationForProvider(preparation: CompactionPreparation): CompactionPreparation; /** Gated cache identity: the agent's explicit key, else the Task family key when `model` opts in. */ getPromptCacheKeyForModel(model: CatalogPolicyInputs | undefined): string | undefined; closeCodexProviderSessionsForHistoryRewrite(): void; resetCodexProviderAfterCompaction(compaction: CodexCompactionContext): void; resetPlanReference(): void; syncTodoPhasesFromBranch(): void; /** `compacted` marks a rewrite that replaced history with a summary. */ resetAdvisorRuntimes(reason?: string, options?: { compacted?: boolean }): void; /** Re-aligns advisors after an in-place prune their own contexts already cover (no re-prime). */ rebaseAdvisorPrefix(reason: string): void; rebaseAfterCompaction(): void; recordAnchoredHistoryRewrite(tokensRemoved: number): 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; tokensFreed: number }>; generateHandoffDocument( customInstructions?: string, options?: SessionHandoffOptions, ): Promise; removeAssistantMessageFromActiveContext(message: AssistantMessage): void; dropPersistedAssistantTurn(message: AssistantMessage): Promise; /** * Keep a terminal failure whose turn was dropped from history visible to * post-settle readers (`AgentSession.getLastAssistantMessage`) until the next run. */ retainTerminalFailure(message: AssistantMessage): void; runRecoveryCompactionWithRollback( reason: "overflow" | "incomplete", message: AssistantMessage, options: { autoContinue: boolean; triggerContextTokens?: number; excludeMediaMethods?: boolean }, ): 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; } /** How a model reads a snapcompact archive: whether it may read reasoning and whether it takes images. */ interface ArchiveReader { /** Its classifier refuses quoted reasoning ({@link snapcompact.readerIncludesThinking}). */ excludeThinking: boolean; /** It takes no image input, so frames would reach it as placeholders. */ withoutFrames: boolean; /** Both flags as one value, for caches and scan bookkeeping. */ key: `${boolean}:${boolean}`; } function archiveReader(model: Model): ArchiveReader { const excludeThinking = !snapcompact.readerIncludesThinking(model); const withoutFrames = !sendsImageInputOnWire(model); return { excludeThinking, withoutFrames, key: `${excludeThinking}:${withoutFrames}` }; } /** * Whether `message` may stand for a snapcompact archive: a snapcompact summary, or one whose * compaction recorded no method (legacy and extension-provided entries), which may still carry one. */ function mayHoldArchive(message: CompactionSummaryMessage): boolean { return message.method === undefined || message.method === "snapcompact"; } /** Owns compaction, pruning, shake, promotion, and automatic context maintenance. */ export class SessionMaintenance { #compactionAbortController: AbortController | undefined; /** Resolves after an active manual compaction has reconnected the agent subscription. */ #manualCompactionCleanup: Promise | undefined; /** Resolves after a manual handoff commits or fails; blocks prompts while its history snapshot is pending. */ #handoffCleanup: Promise | undefined; /** Resolves after an idle pass finishes rewriting history; holds new prompt setup outside that pass. */ #idleCompactionCleanup: Promise | undefined; /** Dispatches holding an unreleased claim from {@link waitForMaintenanceCleanup}/{@link claimPendingResume}; any one may supersede the interrupted-turn resume. */ #promptsAwaitingCleanup = 0; /** Interrupted-turn resume withheld from a compaction `finally` because a claim was open; consumed by `release(false)`, {@link noteTurnStarted}, or the next manual pass. */ #deferredResumeGeneration: number | undefined; #autoCompactionAbortController: AbortController | undefined; /** * Live tool-loop contexts parked after mid-turn maintenance hit a no-progress * dead end. Membership suppresses the repeated rescue + warning while no cut * point exists; {@link maintainContextMidRun} re-arms the entry once a later * tool result makes `prepareCompaction` viable again. */ readonly #midTurnCompactionDeadEnds = new WeakMap(); /** * Carries a mid-turn dead end across the loop's final answer to the next * pre-prompt check. That check must not warn again for the same oversized * persisted turn, but a new agent loop still gets its own live-array guard. */ #midTurnDeadEndPendingPrePrompt: string | null | undefined = undefined; /** Suppress repeated fixed-context notices only within the transcript that received one. */ #fixedContextDeadEndNotifiedSessionId: string | undefined; /** In-flight or armed background speculative compaction, if any. */ #speculation: SpeculationRun | undefined; /** * {@link #nativeSpeculationKey} of the session and model whose native * speculative compaction failed for a reason a retry would hit again (an * exhausted output budget, a tool call, a refusal). Until a compaction * commits, speculation does not re-send the request and the threshold pass * falls back to the next method instead. */ #failedNativeSpeculation: string | undefined; #skipPostTurnMaintenanceAssistantTimestamp: number | undefined; /** * Consecutive no-progress `response.incomplete` (length-stop) recoveries in * the current continuation loop. Bounded by {@link INCOMPLETE_RECOVERY_MAX_RETRIES}; * reset by any turn that produces actionable output and on every new user * prompt ({@link resetForNewPrompt}). */ #incompleteRecoveryAttempts = 0; /** Latest rollover boundary that already received its pre-threshold notebook reminder. */ #experimentalNotesReminderBoundaryId: string | undefined; /** Each archive's no-reasoning view, rendered (or failed closed) once per compaction entry. */ readonly #withoutThinkingViews = new WeakMap>(); /** Each archive's text view for readers without image input, keyed by reader, model and text budget * (`null`: the original already reads as text). */ readonly #withoutFramesViews = new WeakMap>(); /** The compaction entry behind each archive summary message, once found. */ readonly #summaryEntries = new WeakMap(); /** Whether each legendless archive summary message may replay reasoning ({@link #mayReplayThinking}), or the * branch view on which no archive backed it. */ readonly #replaysThinking = new WeakMap(); /** Reader copies that read an archive as text in place of its frames. */ readonly #textCopies = new WeakSet(); /** Reader copies of archive summary messages keyed by the original message, then by {@link ArchiveReader.key}, * so repeated swaps keep one identity. */ readonly #readerSummaries = new WeakMap< AgentMessage, Map >(); /** The original summary message behind each reader copy, restored for readers that read the original. */ readonly #originalSummaries = new WeakMap(); /** How far each live message list was scanned for summaries to swap, and for which kind of reader. */ readonly #summaryScans = new WeakMap< readonly AgentMessage[], { key: string; length: number; first: AgentMessage | undefined } >(); readonly #host: SessionMaintenanceHost; get #model(): Model | undefined { return this.#host.model(); } /** Compaction policy in force for the active model (its `compaction.modelThresholds` entry applied). */ get #compactionSettings(): CompactionSettings { return resolveModelCompactionSettings(this.#host.settings, this.#model); } get #tokenizer() { return this.#host.agent.tokenizer; } /** Frame budget, blob resolution and reading model that rebuild a branch's context as the live session sends it. */ get #contextBuildOptions(): BuildSessionContextOptions { return { maxFrameDataBytes: snapcompact.frameDataBytesBudget(this.#model), resolveFrameData: data => this.#host.sessionManager.resolveFrameData(data), model: this.#model, }; } get #goalModeState(): GoalModeState | undefined { return this.#host.goalModeState(); } constructor(host: SessionMaintenanceHost) { this.#host = host; } /** Experimental rollover is safe only when the current effective tool surface can recover its state. */ #usesExperimentalContextManagement(): boolean { return ( this.#compactionSettings.experimentalContextManagement === true && this.#host.hasExperimentalContextRolloverTools() ); } /** * The notebook prompt is deliberately single-shot per rollover window. It is * injected by the owner at the next normal aside boundary, never by rewriting * a completed provider turn. */ #maybeQueueExperimentalNotesReminder(contextTokens: number, contextWindow: number): void { if (!this.#usesExperimentalContextManagement()) return; const settings = this.#compactionSettings; if (!settings.enabled || this.isCompacting || this.#host.isGeneratingHandoff()) return; const thresholdTokens = resolveThresholdTokens(contextWindow, settings); if (contextTokens >= thresholdTokens) return; if (thresholdTokens - contextTokens > resolveSpeculationLeadTokens(thresholdTokens)) return; const branch = this.#host.sessionManager.getBranch(); const boundaryId = branch.findLast(entry => entry.type === "compaction" || entry.type === "reset_boundary")?.id ?? branch[0]?.id ?? "empty"; if (this.#experimentalNotesReminderBoundaryId === boundaryId) return; this.#experimentalNotesReminderBoundaryId = boundaryId; this.#host.queueExperimentalContextNotesReminder(experimentalContextNotesReminderPrompt); } /** Clears per-prompt recovery counters when a new user prompt starts. */ resetForNewPrompt(): void { this.#incompleteRecoveryAttempts = 0; } /** Whether compaction or a manual handoff is active. */ get isCompacting(): boolean { return ( this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined || this.#handoffCleanup !== undefined ); } /** Background speculative-compaction state, for UI indicators. */ get speculationState(): "idle" | "running" | "armed" { const run = this.#speculation; if (!run) return "idle"; return run.armed ? "armed" : "running"; } /** Completion of the current speculative pass, used to resume maintenance skipped during its handoff. */ get speculationCompletion(): Promise | undefined { return this.#speculation?.promise; } /** Abort and discard any in-flight or armed speculative compaction. */ cancelSpeculation(): void { const run = this.#speculation; if (!run) return; this.#speculation = undefined; run.controller.abort(); } /** 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; } /** * Emit a compaction lifecycle event. Mid-turn callers detach only extension * fan-out for post-commit events; ordered subscriber delivery still completes * before maintenance returns. */ #emitLifecycleEvent(event: AgentSessionEvent, detach: boolean): Promise { return this.#host.emitSessionEvent(event, detach ? { detachExtensions: true } : undefined); } /** * 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] }; } /** * Durably commit a prune pass, restoring the blanked results when the rewrite * fails so live context never diverges from the history its derived state * (advisor prefix, todo phases, provider sessions) was built from. */ async #persistPrune(result: PruneResult): Promise { try { await this.#host.sessionManager.rewriteEntries(); } catch (error) { result.undo(); throw error; } } async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> { const branchEntries = this.#host.sessionManager.getBranchView(); const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId; const model = this.#host.model(); const result = pruneToolOutputs( branchEntries, this.#tokenizer, this.#withPlanProtection({ ...DEFAULT_PRUNE_CONFIG, pruneUseless: this.#compactionSettings.dropUseless, // Cache-stable boundary: never re-write the warm, already-sent prefix // (deep stale/age victims) or summarized-away entries every turn. keepBoundaryId, // Prefix-bound thinking cannot survive rewrites inside a warm provider prefix. cacheWarmSuffixTokens: model?.thinking?.prefixBinding === true ? 0 : PRUNE_CACHE_WARM_SUFFIX_TOKENS, cacheLookbackPositions: model ? resolvePromptCacheLookback(model) : undefined, convertToLlm, }), ); if (result.prunedCount === 0) { return undefined; } await this.#persistPrune(result); const sessionContext = this.#host.buildDisplaySessionContext(); this.#host.replaceMessagesFromSessionContext(sessionContext.messages); this.#host.rebaseAdvisorPrefix("prune-tool-outputs"); 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.#compactionSettings; if (!supersedeReads && !dropUseless) return undefined; const branchEntries = this.#host.sessionManager.getBranchView(); const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId; const model = this.#host.model(); const result = pruneSupersededToolResults( branchEntries, this.#tokenizer, this.#withPlanProtection({ supersedeKey: supersedeReads ? readToolSupersedeKey : undefined, supersedeComplete: isCompleteReadResult, 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, // Prefix-bound thinking cannot survive rewrites inside a warm provider prefix. suffixTokenLimit: model?.thinking?.prefixBinding === true ? 0 : undefined, cacheLookbackPositions: model ? resolvePromptCacheLookback(model) : undefined, convertToLlm, }), ); if (result.prunedCount === 0) { return undefined; } await this.#persistPrune(result); const sessionContext = this.#host.buildDisplaySessionContext(); this.#host.replaceMessagesFromSessionContext(sessionContext.messages); this.#host.rebaseAdvisorPrefix("prune-stale-tool-results"); 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. * * Every stripped block is measured with the active tokenizer so callers can * report real savings, and the share of those savings that predates the * transcript usage anchor is recorded against it. Without that correction * the anchor keeps charging the provider-reported prompt for images that are * gone and the context meter reads stale-high until the next response. * * No-op when the branch carries no images; returns `{ removed: 0, tokensFreed: 0 }` * and skips the disk rewrite. */ async dropImages(): Promise<{ removed: number; tokensFreed: number }> { const branchEntries = this.#host.sessionManager.getBranch(); const countOptions = { excludeEncryptedReasoning: true } as const; const latestCompaction = getLatestCompactionEntry(branchEntries); const compactionIndex = latestCompaction ? branchEntries.lastIndexOf(latestCompaction) : -1; const hasRemoteReplacementHistory = getOpenAiRemoteCompactionPayload(latestCompaction) !== undefined; const anchorIndex = findUsageAnchorIndex(branchEntries, compactionIndex); let removed = 0; let tokensFreed = 0; let anchoredTokensRemoved = 0; for (const [index, entry] of branchEntries.entries()) { // Only entries the anchor's provider-reported prompt already carried may // be subtracted from it, and a remote compaction's replacement history // hides everything before its cutoff from the wire. const anchored = index < anchorIndex && (!hasRemoteReplacementHistory || index > compactionIndex); if (entry.type === "message") { const before = this.#tokenizer.countMessage(entry.message, countOptions); const dropped = stripImagesFromMessage(entry.message); if (dropped === 0) continue; const saved = Math.max(0, before - this.#tokenizer.countMessage(entry.message, countOptions)); removed += dropped; tokensFreed += saved; if (anchored) anchoredTokensRemoved += saved; continue; } if (entry.type !== "custom_message" || typeof entry.content === "string") continue; // Count the message the prompt actually builds rather than a // hand-made probe, so the estimate cannot drift from the projection. const projected = customMessageEntryMessage(entry); if (!projected) continue; const before = this.#tokenizer.countMessage(projected, countOptions); 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) continue; if (kept.length === 0) { kept.push({ type: "text", text: "[image removed]" }); } entry.content = kept; // A spread keeps the second count off the projected message's memoized identity. const saved = Math.max( 0, before - this.#tokenizer.countMessage({ ...projected, content: kept }, countOptions), ); removed += dropped; tokensFreed += saved; if (anchored) anchoredTokensRemoved += saved; } if (removed === 0) { return { removed: 0, tokensFreed: 0 }; } this.#host.recordAnchoredHistoryRewrite(anchoredTokensRemoved); await this.#host.sessionManager.rewriteEntries(); const sessionContext = this.#host.buildDisplaySessionContext(); this.#host.replaceMessagesFromSessionContext(sessionContext.messages); this.#host.resetAdvisorRuntimes("drop-images"); this.#host.closeCodexProviderSessionsForHistoryRewrite(); return { removed, tokensFreed }; } /** * Whether the next request replays this turn's native Responses payload, so * its reasoning travels on the wire regardless of what `content` holds. The * same decision the tokenizer counts with, resolved from the live provider * session state exactly as the request resolves it. */ #replaysNativeHistory(message: AssistantMessage, nativeHistoryReplay: boolean): boolean { return replaysNativeResponsesHistory(message, this.#model, { nativeHistoryReplay }); } /** * Surgically reduce context by dropping heavy content ("shake"). * * - `images` delegates to {@link dropImages}. * - `thinking` removes assistant reasoning blocks without replacement text * and credits their tokens. A turn whose native payload the request * replays is left untouched instead: those blocks are not on the wire, so * removing them would change nothing the model receives and would credit * tokens that stay billed. Kept blocks are reported through * {@link ShakeResult.thinkingBlocksKept}. * - `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, and a `thinking` run whose * every turn is replayed returns without rewriting anything at all. */ async shake( mode: ShakeMode, opts: { config?: ShakeConfig; signal?: AbortSignal; requireArtifact?: boolean; isCurrent?: () => boolean; toolResultsOnly?: boolean; } = {}, ): Promise { if (mode === "images") { const { removed, tokensFreed } = await this.#host.dropImages(); return { mode, toolResultsDropped: 0, blocksDropped: 0, imagesDropped: removed, tokensFreed }; } if (mode === "thinking") { const branchEntries = this.#host.sessionManager.getBranch(); const latestCompaction = getLatestCompactionEntry(branchEntries); const compactionIndex = latestCompaction ? branchEntries.lastIndexOf(latestCompaction) : -1; const hasRemoteReplacementHistory = getOpenAiRemoteCompactionPayload(latestCompaction) !== undefined; const anchorIndex = findUsageAnchorIndex(branchEntries, compactionIndex); let removed = 0; let replayedBlocks = 0; let tokensFreed = 0; let anchoredTokensRemoved = 0; const countOptions = { excludeEncryptedReasoning: true } as const; const model = this.#host.model(); const nativeHistoryReplay = openAIResponsesReplaysNativeHistory(model, this.#host.providerSessionState); for (const [index, entry] of branchEntries.entries()) { if (entry.type !== "message" || entry.message.role !== "assistant") continue; const message = entry.message; const isReasoning = (block: (typeof message.content)[number]) => block.type === "thinking" || block.type === "redactedThinking"; const dropped = message.content.filter(isReasoning).length; if (dropped === 0) continue; // Native Responses replay uses the stored payload instead of content. // Leave those blocks intact: stripping them cannot save wire tokens // and needlessly invalidates the provider's prompt cache. They are // reported as kept, and a run that only keeps blocks rewrites nothing. if (this.#replaysNativeHistory(message, nativeHistoryReplay)) { replayedBlocks += dropped; continue; } const kept = message.content.filter(block => !isReasoning(block)); // Match the stored-context floor: opaque signatures and encrypted // reasoning bytes do not have a reliable provider-token equivalent. const before = this.#tokenizer.countMessage(message, countOptions); // Provider serializers omit empty assistant turns, so don't invent model-authored text. message.content = kept; invalidateMessageCache(message); const saved = Math.max(0, before - this.#tokenizer.countMessage(message, countOptions)); tokensFreed += saved; if (index < anchorIndex && (!hasRemoteReplacementHistory || index > compactionIndex)) { anchoredTokensRemoved += saved; } removed += dropped; } if (removed === 0) { return { mode, toolResultsDropped: 0, blocksDropped: 0, thinkingBlocksDropped: 0, thinkingBlocksKept: replayedBlocks, tokensFreed: 0, }; } this.#host.recordAnchoredHistoryRewrite(anchoredTokensRemoved); await this.#host.sessionManager.rewriteEntries(); const sessionContext = this.#host.buildDisplaySessionContext(); this.#host.replaceMessagesFromSessionContext(sessionContext.messages); this.#host.resetAdvisorRuntimes("shake"); this.#host.closeCodexProviderSessionsForHistoryRewrite(); return { mode, toolResultsDropped: 0, blocksDropped: 0, thinkingBlocksDropped: removed, thinkingBlocksKept: replayedBlocks, tokensFreed, }; } const assertCurrent = () => { if (opts.signal?.aborted || opts.isCurrent?.() === false) throw new CompactionCancelledError(); }; assertCurrent(); const branchEntries = this.#host.sessionManager.getBranch(); const latestCompaction = getLatestCompactionEntry(branchEntries); 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. The cut is // unconditional on the wire (see `buildSessionContext`), so a compaction // the active model cannot replay still hides its prefix from the prompt. keepBoundaryId: latestCompaction?.firstKeptEntryId, }); let regions = collectShakeRegions(branchEntries, this.#tokenizer, config); if (opts.toolResultsOnly) regions = regions.filter(region => region.kind === "toolResult"); if (regions.length === 0) { return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }; } let reservedArtifact: { id?: string; path?: string } = {}; try { reservedArtifact = await this.#host.sessionManager.allocateArtifactPath("shake"); } catch { if (opts.requireArtifact) throw new Error("shake could not save a recovery artifact"); } assertCurrent(); let artifactId = reservedArtifact.id; const calculateReplacementState = (id: string | undefined) => { const replacements = regions.map((region, index) => this.#shakeElidePlaceholder(region, index, id)); const replacementTokenCounts = replacements.map(replacement => replacement.length > 0 ? this.#tokenizer.countTokens(replacement) : 0, ); const savings = regions.reduce( (total, region, index) => total + region.tokens - (replacementTokenCounts[index] ?? 0), 0, ); return { replacements, replacementTokenCounts, savings }; }; let { replacements, replacementTokenCounts, savings } = calculateReplacementState(artifactId); if (opts.toolResultsOnly && savings < config.minSavings) { return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }; } if (reservedArtifact.path && reservedArtifact.id) { try { await writeArtifact(reservedArtifact.path, this.#shakeArtifactText(regions)); } catch { if (opts.requireArtifact) throw new Error("shake could not save a recovery artifact"); artifactId = undefined; ({ replacements, replacementTokenCounts, savings } = calculateReplacementState(artifactId)); } } else { artifactId = await this.#saveShakeArtifact(regions); assertCurrent(); if (opts.requireArtifact && !artifactId) { throw new Error("shake could not save a recovery artifact"); } ({ replacements, replacementTokenCounts, savings } = calculateReplacementState(artifactId)); if (opts.toolResultsOnly && savings < config.minSavings) { return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }; } } assertCurrent(); const hasRemoteReplacementHistory = getOpenAiRemoteCompactionPayload(latestCompaction) !== undefined; const compactionIndex = latestCompaction ? branchEntries.lastIndexOf(latestCompaction) : -1; const anchorIndex = findUsageAnchorIndex(branchEntries, compactionIndex); const entryIndexes = new Map(branchEntries.map((entry, index) => [entry, index])); let toolResultsDropped = 0; let blocksDropped = 0; let originalTokens = 0; let replacementTokens = 0; let anchoredTokensRemoved = 0; const items = regions.map((region, index) => { if (region.kind === "toolResult") toolResultsDropped++; else blocksDropped++; originalTokens += region.tokens; const replacement = replacements[index]; const replacementTokenCount = replacementTokenCounts[index] ?? 0; replacementTokens += replacementTokenCount; const entryIndex = entryIndexes.get(region.entry) ?? -1; if ( entryIndex >= 0 && entryIndex < anchorIndex && (!hasRemoteReplacementHistory || entryIndex > compactionIndex) ) { anchoredTokensRemoved += Math.max(0, region.tokens - replacementTokenCount); } return { region, replacement, replacementTokens: replacementTokenCount }; }); const entrySnapshots = new Map(); for (const { region } of items) { if (!entrySnapshots.has(region.entry)) entrySnapshots.set(region.entry, structuredClone(region.entry)); } const anchorEntry = anchorIndex >= 0 ? branchEntries[anchorIndex] : undefined; if (anchorEntry && !entrySnapshots.has(anchorEntry)) entrySnapshots.set(anchorEntry, structuredClone(anchorEntry)); applyShakeRegions(items); try { this.#host.recordAnchoredHistoryRewrite(anchoredTokensRemoved); await this.#host.sessionManager.rewriteEntries(); } catch (error) { for (const [entry, snapshot] of entrySnapshots) Object.assign(entry, snapshot); // The rewrite just failed, so restore the live history without writing to the session. this.#host.agent.replaceMessages(this.#host.buildDisplaySessionContext().messages); throw error; } const sessionContext = this.#host.buildDisplaySessionContext(); this.#host.replaceMessagesFromSessionContext(sessionContext.messages); this.#host.resetAdvisorRuntimes("shake"); this.#host.closeCodexProviderSessionsForHistoryRewrite(); return { mode, toolResultsDropped, blocksDropped, tokensFreed: Math.max(0, originalTokens - replacementTokens), artifactId, }; } /** One-shot, artifact-backed mechanical reduction for a pre-output Responses body-read timeout. */ async shakeForRequestBodyReadTimeout(generation: number): Promise { const settings = this.#compactionSettings; if (!settings.enabled || !resolveCompactionMethodOrder(settings.methodOrder).includes("shake")) return false; const isCurrent = () => !this.#host.isDisposed() && this.#host.promptGeneration() === generation; if (!isCurrent()) return false; try { const result = await this.shake("elide", { config: DEFAULT_SHAKE_CONFIG, requireArtifact: true, isCurrent, toolResultsOnly: true, }); return isCurrent() && result.toolResultsDropped + result.blocksDropped > 0; } catch (error) { if (!(error instanceof CompactionCancelledError)) { this.#host.emitNotice("warning", "Could not safely reduce this request before retrying it.", "compaction"); } return false; } } #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 original regions into the persisted shake artifact body. */ #shakeArtifactText(regions: ShakeRegion[]): string { 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, ""); } return parts.join("\n"); } /** Persist the shake artifact, using the session manager's in-memory fallback when needed. */ async #saveShakeArtifact(regions: ShakeRegion[]): Promise { try { return await this.#host.sessionManager.saveArtifact(this.#shakeArtifactText(regions), "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 * @param onCommitted Internal: nested fallback frames report their commit to * the owning frame the moment the entry lands, so a late rejection (a * `session_compact` hook or `onComplete` throwing after the append) still * resumes the interrupted turn exactly as the direct path does. */ async compact( customInstructions?: string, options?: CompactOptions, methodOffset = 0, retryController?: AbortController, onCommitted?: () => void, ): Promise { const ownsCompactionController = retryController === undefined; if ( this.#handoffCleanup || (this.#compactionAbortController && this.#compactionAbortController !== retryController) ) { 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.`); } let methods: CompactionMethod[] = []; let selectedMethodIndex = -1; let compactionCommitted = false; const markCommitted = (): void => { compactionCommitted = true; onCommitted?.(); }; let methodAttempted = false; // No-op rejections (too small / already compacted) make no history change // in this pass, so an interrupted or inherited turn resumes on them like on // a commit; a hook cancel or summarizer failure does not resume. let rejectedAsNoOp = false; // Set when this manual pass aborted a live turn — or inherited a resume an // earlier pass withheld for a still-parked prompt (see // `waitForManualCompactionCleanup`): the turn is resumed once the summary // lands (see the `finally`). Generation is captured after the abort bump so // a reset/new-session in between skips the resume as stale. This pass's // options gate both (a `suppressContinuation` caller owns whatever turn // follows, e.g. plan-mode approve-and-compact); its outcome gates only the // turn it interrupted itself — see the `finally`. let resumeInterruptedTurn = false; let interruptedTurnGeneration = 0; let inheritedResume = false; // The owning pass reports progress as one `manual_compaction_start`/`_end` // pair; nested fallback passes return their result through it. let manualCompactionStarted = false; let manualCompactionResult: CompactionResult | undefined; let manualCompactionError: unknown; const finish = (result: CompactionResult): CompactionResult => { manualCompactionResult = result; return result; }; const compactionAbortController = retryController ?? new AbortController(); const manualCompactionCleanup = ownsCompactionController ? Promise.withResolvers() : undefined; if (ownsCompactionController) { this.#compactionAbortController = compactionAbortController; this.#manualCompactionCleanup = manualCompactionCleanup?.promise; // A resume still withheld from an earlier pass belongs to this pass now: // the earlier pass's abort already bumped the generation it captured, so // re-decide with this pass's generation instead of stranding the turn. inheritedResume = this.#deferredResumeGeneration !== undefined; this.#deferredResumeGeneration = undefined; } // A manual pass supersedes any background speculation; running both would // double-bill the summarizer and race the commit. this.cancelSpeculation(); try { if (ownsCompactionController) { // A manual compaction aborts the live turn, tool loop included. Without a // resume the agent sits idle on a half-finished loop (an autoresearch run, // a pending tool result) until the user types "continue" by hand. // Only a turn the agent actually owns counts: the session-level busy flag // is also true while a prompt is still in async setup (before its message // reaches the agent). The abort bump drops that prompt, so resuming on // its behalf would nudge the model on the previous transcript instead. const interruptedActiveTurn = this.#host.agent.state.isStreaming; this.#host.disconnectFromAgent(); await this.#host.abort({ goalReason: "internal", preserveCompaction: true }); resumeInterruptedTurn = (interruptedActiveTurn || inheritedResume) && options?.suppressContinuation !== true && cfgCompactionAutoContinue.get(this.#host.settings) !== false; interruptedTurnGeneration = this.#host.promptGeneration(); manualCompactionStarted = true; await this.#emitLifecycleEvent( { type: "manual_compaction_start", ...(compactMode ? { mode: compactMode.name } : {}) }, false, ); } const activeModel = this.#model; if (!activeModel) { throw new Error("No model selected"); } if ( this.#usesExperimentalContextManagement() && !options?.mode && !customInstructions && !options?.internalGuidance ) { const result = await this.#compactExperimentalContext( activeModel, compactionAbortController, markCommitted, ); options?.onComplete?.(result); return finish(result); } const compactionSettings = this.#compactionSettings; // A reader that cannot replay the active model's native payload rules out the remote method; its // native lanes run before a configured endpoint, so an endpoint does not make it portable. const readerModel = options?.readerModel; methods = resolveCompactionMethodOrder( compactMode?.overrides.methodOrder ?? compactionSettings.methodOrder, ).filter( method => method !== "remote" || !readerModel || canReplayRemoteCompactionFrom(activeModel, readerModel), ); const explicitSnapcompact = compactMode?.name === "snapcompact"; const runsManually = (method: CompactionMethod): boolean => { switch (method) { case "remote": return canUseRemoteCompaction(activeModel, resolveMethodSettings(compactionSettings, method)); case "snapcompact": return ( explicitSnapcompact || (!customInstructions && !options?.internalGuidance && sendsImageInputOnWire(activeModel)) ); case "soft": return true; case "handoff": case "shake": return false; } }; selectedMethodIndex = methods.findIndex((method, index) => index >= methodOffset && runsManually(method)); const selectedMethod = methods[selectedMethodIndex]; if (!selectedMethod) { throw new Error("No configured compaction method can run manually."); } // `/compact snapcompact` pins the order to snapcompact alone, so its // blockers name the `/compact` mode of the next manual method (remote or // soft): plain `/compact` can run an experimental rollover instead. const snapcompactFailure = (reason: string): Error => { if (!explicitSnapcompact) return new Error(reason); const fallback = resolveCompactionMethodOrder(compactionSettings.methodOrder).find( method => method !== "snapcompact" && runsManually(method), ) ?? "soft"; return new Error(`${reason}. Run /compact ${fallback} instead.`); }; const effectiveSettings = resolveMethodSettings(compactionSettings, selectedMethod); const availableModels = this.#host.modelRegistry.getAvailable(); const requireProviderRemote = selectedMethod === "remote" && !effectiveSettings.remoteEndpoint; const remoteReader = selectedMethod === "remote" ? options?.readerModel : undefined; const compactionCandidates = this.#getCompactionModelCandidates( availableModels, requireProviderRemote || remoteReader ? candidate => (!requireProviderRemote || canUseLiveProviderNativeCompaction(candidate, activeModel, effectiveSettings)) && (!remoteReader || canReplayRemoteCompactionFrom(candidate, remoteReader)) : undefined, ); if ((requireProviderRemote || remoteReader) && compactionCandidates.length === 0) { this.#host.emitNotice( "warning", `remote compaction is unavailable for ${activeModel.id}; trying the next preferred method`, "compaction", ); // The nested call runs on this controller, so it never owns the resume; // it reports its commit back here instead. return finish( await this.compact( customInstructions, options, selectedMethodIndex + 1, compactionAbortController, markCommitted, ), ); } const pathEntries = this.#host.sessionManager.getBranch(); const preparation = prepareCompaction(pathEntries, effectiveSettings, activeModel, this.#tokenizer); if (!preparation) { // Check why we can't compact const lastEntry = pathEntries[pathEntries.length - 1]; if (lastEntry?.type === "compaction") { throw new ManualCompactionNoOpError("Already compacted"); } throw new ManualCompactionNoOpError("Nothing to compact (session too small)"); } let hookCompaction: CompactionResult | undefined; let fromExtension = false; let preserveData: Record | undefined; let providerReplayThroughEntryId: string | 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, compactionAbortController.signal, ); if (compactionPrep.kind !== "fromHook") methodAttempted = true; // Focus instructions require an LLM summary, so the preference resolver // only selects snapcompact for an undirected manual compaction. const wantsSnapcompact = compactionPrep.kind !== "fromHook" && selectedMethod === "snapcompact"; const snapcompactReady = wantsSnapcompact; const snapcompactShapeSetting = cfgSnapcompactShape.get(this.#host.settings); let snapcompactShape: snapcompact.Shape | undefined; const snapcompactIncludeThinking = snapcompact.readerIncludesThinking(this.#model); if (wantsSnapcompact && !sendsImageInputOnWire(this.#model)) { const reason = this.#model.input.includes("image") ? `${this.#model.id}'s provider strips image input` : `${this.#model.id} is text-only`; throw snapcompactFailure(`snapcompact needs a vision-capable model (${reason})`); } 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); throw snapcompactFailure( `snapcompact cannot render this conversation: 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 room // under the compaction trigger via #computeSnapcompactMaxFrames (and // never past the window fit, issue #3247). A local blocker rejects // this method, allowing the configured preference order to continue. let snapcompactResult: snapcompact.CompactionResult | undefined; if (snapcompactReady) { const maxFrames = this.#computeSnapcompactMaxFrames(preparation, effectiveSettings, 0); if (maxFrames < 1) { logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", { model: this.#model?.id, }); throw snapcompactFailure("snapcompact cannot run: kept history alone exceeds the context budget"); } else { const shape = snapcompactShape; if (!shape) { throw new Error("snapcompact shape was not resolved before rendering."); } const rendered = await this.#renderSnapcompactArchive( preparation, { convertToLlm, model: this.#model, ...(snapcompactShapeSetting === "auto" ? {} : { shape }), maxFrames, includeThinking: snapcompactIncludeThinking, }, effectiveSettings, 0, ); snapcompactResult = rendered.result; const framePayloadBytes = rendered.framePayloadBytes; const frameByteBudget = snapcompact.frameDataBytesBudget(this.#model); if (framePayloadBytes > frameByteBudget) { logger.warn("Snapcompact exceeded the per-request frame payload budget", { model: this.#model?.id, framePayloadBytes, budget: frameByteBudget, }); throw snapcompactFailure("snapcompact produced too much standing image payload for one request"); } const ctxWindow = this.#model?.contextWindow ?? 0; const budget = ctxWindow > 0 ? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings) : Number.POSITIVE_INFINITY; const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult); // No-reduction decision runs in the encrypted-reasoning-excluded domain // on both sides: opaque replay signatures (thinkingSignature / // redactedThinking) have no trustworthy local token price, so counting // them in the archived region's baseline while the imaged projection // drops them lets an inflating result pass the guard (#10716). The // window-fit check below keeps the full `projected`, which charges the // replayed reasoning the reader bills. const projectedForReduction = this.#projectSnapcompactContextTokens(preparation, snapcompactResult, { excludeEncryptedReasoning: true, }); const reductionBaseline = this.#projectPreSnapcompactContextTokens(preparation); if (projectedForReduction >= reductionBaseline) { logger.warn("Snapcompact projection would not reduce context", { model: this.#model?.id, projected: projectedForReduction, reductionBaseline, }); throw snapcompactFailure("snapcompact would not reduce context"); } if (projected > budget) { logger.warn("Snapcompact still overflows the window after frame-budget sizing", { model: this.#model?.id, }); throw snapcompactFailure("snapcompact could not bring the context under the limit"); } } } if (compactionPrep.kind === "fromHook") { summary = compactionPrep.summary; shortSummary = compactionPrep.shortSummary; firstKeptEntryId = compactionPrep.firstKeptEntryId; tokensBefore = compactionPrep.tokensBefore; details = compactionPrep.details; preserveData = compactionPrep.preserveData; providerReplayThroughEntryId = compactionPrep.providerReplayThroughEntryId; } 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, loadExtraContext: compactionPrep.loadMemoryContext, // The prompt the live turn actually sent — a per-turn // `before_agent_start` override included — so a provider-native // compaction shares the live request's cached prefix. remoteSystemPrompt: this.#host.agent.state.systemPrompt, 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); providerReplayThroughEntryId = result.providerReplayThroughEntryId; } catch (err) { if (err instanceof CompactionCancelledError) { if (!compactionAbortController.signal.aborted || err.cause !== undefined) throw err; throw new CompactionCancelledError(err.message, { cause: compactionAbortController.signal.reason, }); } if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") { throw new CompactionCancelledError(undefined, { cause: compactionAbortController.signal.reason, }); } throw err; } } if (compactionAbortController.signal.aborted) { throw new CompactionCancelledError(undefined, { cause: compactionAbortController.signal.reason, }); } markCommitted(); await this.#commitCompactionEntry({ summary, shortSummary, firstKeptEntryId, tokensBefore, details, fromExtension, preserveData, method: fromExtension ? undefined : selectedMethod, codexCompaction, providerReplayThroughEntryId, advisorResetReason: "compact", }); const compactionResult: CompactionResult = { summary, shortSummary, firstKeptEntryId, tokensBefore, details, preserveData: snapcompact.stripPreservedArchive(preserveData), ...(providerReplayThroughEntryId ? { providerReplayThroughEntryId } : {}), }; options?.onComplete?.(compactionResult); return finish(compactionResult); } catch (error) { manualCompactionError = error; const err = toError(error); if (error instanceof ManualCompactionNoOpError) rejectedAsNoOp = true; if ( methodAttempted && !compactionCommitted && !compactionAbortController.signal.aborted && !(error instanceof CompactionCancelledError) && selectedMethodIndex >= 0 && selectedMethodIndex + 1 < methods.length ) { this.#host.emitNotice( "warning", `${methods[selectedMethodIndex]} compaction failed (${err.message}); trying the next preferred method`, "compaction", ); try { return finish( await this.compact( customInstructions, options, selectedMethodIndex + 1, compactionAbortController, markCommitted, ), ); } catch (fallbackError) { // The end event reports the last method's failure, not the first. manualCompactionError = fallbackError; throw fallbackError; } } options?.onError?.(err); throw error; } finally { if (ownsCompactionController) { if (manualCompactionStarted) { await this.#emitManualCompactionEnd( manualCompactionResult, manualCompactionError, compactionAbortController.signal.aborted, ); } 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(); if (this.#manualCompactionCleanup === manualCompactionCleanup?.promise) { this.#manualCompactionCleanup = undefined; } manualCompactionCleanup?.resolve(); // An inherited resume was earned by the pass that committed it; this // pass's own outcome (hook cancel, summarizer failure) only decides the // turn it interrupted itself, so the inherited one stays owed. if ((compactionCommitted || rejectedAsNoOp || inheritedResume) && resumeInterruptedTurn) { if (this.#promptsAwaitingCleanup > 0) { // A prompt parked on the barrier just resolved is the user's next // intent and takes the session instead (its continuation is a // microtask away). It reports back via `release`: a turn drops the // resume, a locally handled command hands it back. this.#deferredResumeGeneration = interruptedTurnGeneration; } else { this.#scheduleInterruptedTurnResume(interruptedTurnGeneration); } } } } } /** Close the `manual_compaction_start` a manual pass emitted, with its outcome. */ #emitManualCompactionEnd( result: CompactionResult | undefined, error: unknown, signalAborted: boolean, ): Promise { if (result) return this.#emitLifecycleEvent({ type: "manual_compaction_end", result, aborted: false }, false); const errorMessage = error instanceof Error ? error.message : error === undefined ? "Compaction failed" : String(error); if (error instanceof ManualCompactionNoOpError) { return this.#emitLifecycleEvent( { type: "manual_compaction_end", aborted: false, skipped: true, errorMessage }, false, ); } const aborted = signalAborted || error instanceof CompactionCancelledError; return this.#emitLifecycleEvent({ type: "manual_compaction_end", aborted, errorMessage }, false); } /** * Commit a notebook-backed boundary without generating, imaging, or otherwise * rewriting the canonical transcript. Explicit compact modes and focused * manual compactions deliberately bypass this path. */ async #compactExperimentalContext( model: Model, signalController: AbortController, onCommitted: () => void, ): Promise { const entries = this.#host.sessionManager.getBranch(); const settings = this.#compactionSettings; const preparation = prepareCompaction(entries, settings, model, this.#tokenizer); if (!preparation) throw new ManualCompactionNoOpError("Nothing to compact (session too small or already rolled over)"); const sourceLeafId = entries.at(-1)?.id; const sourceSessionId = this.#host.sessionId(); const sourceModel = `${model.provider}/${model.id}`; let hookCompaction: CompactionResult | undefined; let fromExtension = false; if (this.#host.extensionRunner?.hasHandlers("session_before_compact")) { const hookResult = (await this.#host.extensionRunner.emit({ type: "session_before_compact", preparation, branchEntries: entries, customInstructions: undefined, signal: signalController.signal, })) as SessionBeforeCompactResult | undefined; if (hookResult?.cancel) throw new CompactionCancelledError(); if (hookResult?.compaction) { hookCompaction = hookResult.compaction; fromExtension = true; } } if ( signalController.signal.aborted || !this.#usesExperimentalContextManagement() || this.#host.sessionId() !== sourceSessionId || this.#host.sessionManager.getBranch().at(-1)?.id !== sourceLeafId || (this.#model && `${this.#model.provider}/${this.#model.id}` !== sourceModel) ) { throw new CompactionCancelledError(undefined, { cause: signalController.signal.reason }); } const prepared = await this.#prepareCompactionFromHooks(preparation, hookCompaction, signalController.signal); if ( signalController.signal.aborted || !this.#usesExperimentalContextManagement() || this.#host.sessionId() !== sourceSessionId || this.#host.sessionManager.getBranch().at(-1)?.id !== sourceLeafId || (this.#model && `${this.#model.provider}/${this.#model.id}` !== sourceModel) ) { throw new CompactionCancelledError(undefined, { cause: signalController.signal.reason }); } const result = prepared.kind === "fromHook" ? { summary: prepared.summary, shortSummary: prepared.shortSummary, firstKeptEntryId: prepared.firstKeptEntryId, tokensBefore: prepared.tokensBefore, details: { kind: "experimental-context-rollover", version: 1, hookDetails: prepared.details, }, preserveData: prepared.preserveData, providerReplayThroughEntryId: prepared.providerReplayThroughEntryId, } : { summary: experimentalContextRolloverPrompt, shortSummary: undefined, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, details: { kind: "experimental-context-rollover", version: 1 }, preserveData: prepared.preserveData, }; // Report before `#commitCompactionEntry`, as the direct path does: it appends // the entry and then awaits post-append bookkeeping and the `session_compact` // hook, and a rejection there must still count as committed for the // interrupted-turn resume. onCommitted(); await this.#commitCompactionEntry({ ...result, fromExtension, method: undefined, codexCompaction: undefined, advisorResetReason: "experimental-context-rollover", }); this.#experimentalNotesReminderBoundaryId = undefined; return { ...result, preserveData: snapcompact.stripPreservedArchive(result.preserveData), }; } /** * Automatic notebook rollover. This deliberately avoids the ordinary * method-order pipeline: snapcompact, shake, provider-native replacement, * and LLM summaries all rewrite the active representation of raw history. */ async #runExperimentalContextRollover( reason: "overflow" | "threshold" | "idle" | "incomplete", willRetry: boolean, options: { autoContinue?: boolean; suppressContinuation?: boolean; terminalTextAnswer?: boolean; triggerContextTokens?: number; explicitNewContextRequest?: boolean; detachPostCommit?: boolean; }, ): Promise { const model = this.#model; if (!model || this.isCompacting) return COMPACTION_CHECK_NONE; const settings = this.#compactionSettings; const branch = this.#host.sessionManager.getBranch(); const preparation = prepareCompaction(branch, settings, model, this.#tokenizer); if (!preparation) return COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION; const sourceLeafId = branch.at(-1)?.id; const sourceSessionId = this.#host.sessionId(); const sourceModel = `${model.provider}/${model.id}`; this.cancelSpeculation(); this.#autoCompactionAbortController?.abort(); const controller = new AbortController(); this.#autoCompactionAbortController = controller; const generation = this.#host.promptGeneration(); const suppressContinuation = options.suppressContinuation === true; const shouldAutoContinue = !suppressContinuation && options.autoContinue !== false && settings.autoContinue !== false; const terminalTextAnswer = options.terminalTextAnswer ?? isTerminalTextAssistantAnswer(this.#host.findLastAssistantMessage()); const detachPostCommit = options.detachPostCommit === true; try { await this.#emitLifecycleEvent({ type: "auto_compaction_start", reason, action: "context-full" }, false); if (controller.signal.aborted) { await this.#emitLifecycleEvent( { type: "auto_compaction_end", action: "context-full", result: undefined, aborted: true, willRetry: false, }, detachPostCommit, ); return COMPACTION_CHECK_NONE; } let hookCompaction: CompactionResult | undefined; let fromExtension = false; if (this.#host.extensionRunner?.hasHandlers("session_before_compact")) { const hookResult = (await this.#host.extensionRunner.emit({ type: "session_before_compact", preparation, branchEntries: this.#host.sessionManager.getBranch(), customInstructions: undefined, signal: controller.signal, })) as SessionBeforeCompactResult | undefined; if (hookResult?.cancel) { await this.#emitLifecycleEvent( { type: "auto_compaction_end", action: "context-full", result: undefined, aborted: true, willRetry: false, }, detachPostCommit, ); return COMPACTION_CHECK_NONE; } if (hookResult?.compaction) { hookCompaction = hookResult.compaction; fromExtension = true; } } if (controller.signal.aborted || !this.#usesExperimentalContextManagement()) { await this.#emitLifecycleEvent( { type: "auto_compaction_end", action: "context-full", result: undefined, aborted: controller.signal.aborted, willRetry: false, skipped: !controller.signal.aborted, }, detachPostCommit, ); return COMPACTION_CHECK_NONE; } const prepared = await this.#prepareCompactionFromHooks(preparation, hookCompaction, controller.signal); if ( controller.signal.aborted || !this.#usesExperimentalContextManagement() || this.#host.sessionId() !== sourceSessionId || this.#host.sessionManager.getBranch().at(-1)?.id !== sourceLeafId || (this.#model && `${this.#model.provider}/${this.#model.id}` !== sourceModel) ) { await this.#emitLifecycleEvent( { type: "auto_compaction_end", action: "context-full", result: undefined, aborted: controller.signal.aborted, willRetry: false, skipped: !controller.signal.aborted, }, detachPostCommit, ); return COMPACTION_CHECK_NONE; } const result = prepared.kind === "fromHook" ? { summary: prepared.summary, shortSummary: prepared.shortSummary, firstKeptEntryId: prepared.firstKeptEntryId, tokensBefore: options.triggerContextTokens ?? prepared.tokensBefore, details: { kind: "experimental-context-rollover", version: 1, hookDetails: prepared.details, }, preserveData: prepared.preserveData, providerReplayThroughEntryId: prepared.providerReplayThroughEntryId, } : { summary: experimentalContextRolloverPrompt, shortSummary: undefined, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: options.triggerContextTokens ?? preparation.tokensBefore, details: { kind: "experimental-context-rollover", version: 1 }, preserveData: prepared.preserveData, }; await this.#commitCompactionEntry({ ...result, fromExtension, method: undefined, codexCompaction: undefined, advisorResetReason: "experimental-context-rollover", detachExtensionEmit: detachPostCommit, }); this.#experimentalNotesReminderBoundaryId = undefined; const compactionResult: CompactionResult = { ...result, preserveData: snapcompact.stripPreservedArchive(result.preserveData), }; const safeToContinue = willRetry ? this.#compactionCreatedRetryFit() : reason === "idle" || this.#compactionCreatedHeadroom(); await this.#emitLifecycleEvent( { type: "auto_compaction_end", action: "context-full", result: compactionResult, aborted: false, willRetry, }, detachPostCommit, ); if (!safeToContinue) { const warning = this.#compactionDeadEndWarning("clear large tool output"); const entry = getLatestCompactionEntry(this.#host.sessionManager.getBranch()); if (entry) { entry.warning = warning; await this.#host.sessionManager.rewriteEntries(); } this.#host.emitNotice("warning", warning, "compaction"); return COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION; } if (willRetry) { this.#host.scheduleAgentContinue({ source: "experimental-context-rollover-retry", delayMs: 100, generation, }); return COMPACTION_CHECK_CONTINUATION; } return this.#host.scheduleCompactionContinuation({ generation, autoContinue: shouldAutoContinue, terminalTextAnswer, suppressContinuation, }) ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE; } catch (error) { await this.#emitLifecycleEvent( { type: "auto_compaction_end", action: "context-full", result: undefined, aborted: controller.signal.aborted, willRetry: false, errorMessage: error instanceof Error ? error.message : String(error), }, detachPostCommit, ); throw error; } finally { if (this.#autoCompactionAbortController === controller) { this.#autoCompactionAbortController = undefined; } } } /** * Ask the active memory backend for context when a compaction summary * consumes it. Native OpenAI compaction uses the live prompt instead. * * 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[]; }, signal: AbortSignal, ): 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(), signal, ); } catch (err) { logger.debug("Memory backend preCompactionContext failed", { backend: backend.id, error: String(err), }); return undefined; } } /** * Cancel in-progress context maintenance and return the active manual pass's * cleanup barrier. The barrier resolves only after its agent subscription reconnects. */ abortCompaction(reason?: unknown): Promise | undefined { const manualCompactionCleanup = this.#manualCompactionCleanup ?? this.#handoffCleanup; this.#compactionAbortController?.abort(reason); this.#autoCompactionAbortController?.abort(reason); this.#host.abortHandoff(); return manualCompactionCleanup; } /** * Same continuation the context-full path uses: a queued steer/follow-up * (drained in compact()'s `finally`) drives the resume, otherwise the * auto-continue nudge does. `terminalTextAnswer` is false by construction — * the turn was cut mid-run, so there is no finished answer to preserve. */ #scheduleInterruptedTurnResume(generation: number): void { this.#host.scheduleCompactionContinuation({ generation, autoContinue: true, terminalTextAnswer: false, suppressContinuation: false, }); } /** * Park prompt setup until idle compaction, manual compaction, or manual handoff * finishes rewriting history. A prompt parked by a manual pass supersedes its * interrupted-turn resume; local commands and failed dispatches release that * claim so the interrupted turn is not stranded. * * Returns `undefined` when neither maintenance pass nor a resume decision is * open. Otherwise the caller MUST invoke the returned `release` after dispatch. */ async waitForMaintenanceCleanup(): Promise<((startedTurn: boolean) => void) | undefined> { while (this.#idleCompactionCleanup) await this.#idleCompactionCleanup; const cleanup = this.#manualCompactionCleanup ?? this.#handoffCleanup; // No compaction to wait for, but an earlier parked prompt is still settling: // this prompt competes for the same session, so it takes part in the // resume decision (a turn it starts must not be followed by the stale nudge). if (!cleanup) return this.claimPendingResume(); const release = this.#claimResume(); await cleanup; return release; } /** * The active idle or manual compaction's history-replacement barrier, if any. * Idle turn starters wait for it before reading the agent's messages; the * common path adds no await. Manual compaction resolves it after reconnecting * the persistence subscription. */ waitForContextReplacement(): Promise | undefined { return this.#manualCompactionCleanup ?? this.#idleCompactionCleanup; } /** * Register a dispatch that may start a turn while an interrupted-turn resume * decision is open (a prompt parked by {@link waitForMaintenanceCleanup} * has not released yet). `undefined` when no decision is open. * * The caller MUST invoke the returned `release` once the dispatch settles. * `startedTurn: true` (dispatched or queued) drops the deferred resume for * good; `false` (an extension/custom command handled locally, a pre-dispatch * throw) hands it back so the interrupted work is not stranded, once no other * participant can still claim the session. */ claimPendingResume(): ((startedTurn: boolean) => void) | undefined { return this.#promptsAwaitingCleanup > 0 ? this.#claimResume() : undefined; } #claimResume(): (startedTurn: boolean) => void { this.#promptsAwaitingCleanup++; let released = false; return (startedTurn: boolean): void => { if (released) return; released = true; this.#promptsAwaitingCleanup--; if (startedTurn) { this.#deferredResumeGeneration = undefined; return; } if (this.#promptsAwaitingCleanup > 0 || this.#deferredResumeGeneration === undefined) return; const generation = this.#deferredResumeGeneration; this.#deferredResumeGeneration = undefined; this.#scheduleInterruptedTurnResume(generation); }; } /** * A turn started, by whatever path (a released prompt, an extension's * `sendMessage({ triggerTurn })`, the queued-message drain). It owns the * session now, so a resume still withheld for a parked prompt is moot. */ noteTurnStarted(): void { this.#deferredResumeGeneration = undefined; } /** 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 || this.#idleCompactionCleanup) return; const cleanup = Promise.withResolvers(); this.#idleCompactionCleanup = cleanup.promise; try { await this.runAutoCompaction("idle", false); } finally { this.#idleCompactionCleanup = undefined; cleanup.resolve(); } } /** * Manual handoff: generate a handoff document and commit it as a compaction * entry on the current session — the document becomes the summary and recent * history is kept per `compaction.keepRecentTokens`. Unlike `/compact`, the * live agent is not aborted; generation reads a snapshot of the live * messages through the cache-friendly side-request pipeline. Prompts wait * until the commit or failure completes. */ async handoff(customInstructions?: string, options?: SessionHandoffOptions): Promise { if (this.isCompacting) throw new Error("Compaction already in progress"); this.cancelSpeculation(); const model = this.#model; if (!model) throw new Error("No model selected for handoff"); const entries = this.#host.sessionManager.getBranch(); const messageCount = entries.filter(e => e.type === "message").length; if (messageCount < 2) throw new Error("Nothing to hand off (no messages yet)"); const compactionSettings = this.#compactionSettings; const preparation = prepareCompaction( entries, resolveMethodSettings(compactionSettings, "handoff"), model, this.#tokenizer, ); if (!preparation) throw new Error("Nothing to hand off (already compacted)"); const cleanup = Promise.withResolvers(); this.#handoffCleanup = cleanup.promise; try { const result = await this.#host.generateHandoffDocument(customInstructions, options); if (!result) return undefined; const { summary, details } = handoffSummaryFromDocument(result.document, preparation); await this.#commitCompactionEntry({ summary, shortSummary: undefined, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, details, fromExtension: false, preserveData: undefined, method: "handoff", codexCompaction: undefined, advisorResetReason: "handoff", }); return result; } finally { this.#handoffCleanup = undefined; cleanup.resolve(); } } /** * Start a background speculative compaction when context has entered the * pre-threshold band `[threshold − lead, threshold)`. The produced summary * is held (armed) and committed instantly by the next real maintenance * pass, hiding summarization latency. Only LLM-backed methods * (remote/handoff/soft) are speculated — shake and snapcompact are local * and effectively instant. Never rewrites history itself; stale results are * discarded by apply-time branch validation in {@link #claimArmedSpeculation}. * A turn that jumps past the threshold before a run armed is handled by * {@link deferThresholdCompactionToSpeculation}'s grace band instead. */ maybeStartSpeculativeCompaction(contextTokens: number, contextWindow: number): void { if (contextWindow <= 0 || this.#host.isDisposed()) return; const settings = this.#compactionSettings; if (this.#usesExperimentalContextManagement()) { this.#maybeQueueExperimentalNotesReminder(contextTokens, contextWindow); return; } if (!settings.enabled || settings.asyncEnabled === false || !hasConfiguredCompactionMethod(settings)) return; if (this.isCompacting || this.#host.isGeneratingHandoff()) return; // Extensions that intercept compaction (cancel/replace) keep exact // blocking semantics; a speculated result would bypass their veto. if (this.#host.extensionRunner?.hasHandlers("session_before_compact")) return; const thresholdTokens = resolveThresholdTokens(contextWindow, settings); if (contextTokens >= thresholdTokens) return; // real maintenance owns it now if (thresholdTokens - contextTokens > resolveSpeculationLeadTokens(thresholdTokens)) return; const current = this.#speculation; if (current) { if (!current.armed) return; // one run at a time // Refresh-on-growth: the armed summary's kept tail grows with every // turn; once the growth exceeds the keep-recent budget, a fresh cut // reclaims materially more context at apply time. const growth = contextTokens - current.armed.contextTokensAtStart; const refreshBudget = Math.max(settings.keepRecentTokens, SPECULATION_LEAD_MIN_TOKENS); if ( (growth <= refreshBudget || isRecord(current.armed.result.preserveData?.anthropicCompaction)) && this.#armedSpeculationValid(current.armed, contextTokens) ) return; this.cancelSpeculation(); } const model = this.#model; if (!model) return; const method = resolveSpeculationMethod(model, settings, { skipRemote: this.#nativeSpeculationFailed(model), }); if (!method) return; this.#startSpeculationRun(contextTokens, method); } /** Install and launch one background speculation run for `method`. */ #startSpeculationRun(contextTokens: number, method: "remote" | "handoff" | "soft"): void { const controller = new AbortController(); const run: SpeculationRun = { controller, promise: Promise.resolve(), contextTokensAtStart: contextTokens }; const model = this.#model; // Keyed now: the session can switch before the run settles. const nativeKey = model && this.#nativeSpeculationKey(model); this.#speculation = run; run.promise = this.#runSpeculation(run, method, contextTokens).catch(error => { logger.debug("Speculative compaction failed", { method, error: error instanceof Error ? error.message : String(error), }); if (this.#speculation === run) this.#speculation = undefined; // Keyed by the live model even when a compaction-model candidate made the // failing request: every consumer checks the live model, and a false hit // only moves this cycle to the next configured method. Errors with no // HTTP status classify by message text, so an unrecognised transient // failure costs the same single early fallback. if ( method === "remote" && model && !controller.signal.aborted && error instanceof NativeCompactionError && !AIError.retriable(AIError.classify(error.cause, model.api)) ) { this.#failedNativeSpeculation = nativeKey; } }); } /** Identity of `model`'s native speculation in the current session. */ #nativeSpeculationKey(model: Model): string { return `${this.#host.sessionManager.getSessionId()}/${model.provider}/${model.id}`; } /** Whether `model`'s native speculation already failed for good this cycle. */ #nativeSpeculationFailed(model: Model): boolean { return this.#failedNativeSpeculation === this.#nativeSpeculationKey(model); } /** * Grace band above the compaction threshold: when a single turn jumps past * the threshold before the background speculation armed (or even started), * the threshold pass keeps serving the user instead of blocking on a * synchronous summarization — the speculation finishes in the background * and the next maintenance boundary splices it in for free. Returns true * while deferral is in effect (a run was live, or one was started here); * the caller MUST skip its blocking compaction then. * * Deferral ends — and the blocking pass resumes — once context grows past * `threshold + lead`, clamped to keep {@link SPECULATION_LEAD_MIN_TOKENS} * of headroom below the window. A provider overflow inside the band is * recovered by the existing overflow path (compact + retry). Never defers * for local-first method orders (shake/snapcompact are instant), when * async compaction is disabled, or when a `session_before_compact` * extension must keep exact blocking semantics. */ deferThresholdCompactionToSpeculation(contextTokens: number, contextWindow: number): boolean { if (contextWindow <= 0 || this.#host.isDisposed()) return false; const settings = this.#compactionSettings; if (this.#usesExperimentalContextManagement()) return false; if (!settings.enabled || settings.asyncEnabled === false || !hasConfiguredCompactionMethod(settings)) return false; if (this.isCompacting || this.#host.isGeneratingHandoff()) return false; if (this.#host.extensionRunner?.hasHandlers("session_before_compact")) return false; const model = this.#model; if (!model) return false; const method = resolveSpeculationMethod(model, settings, { skipRemote: this.#nativeSpeculationFailed(model), }); if (!method) return false; const thresholdTokens = resolveThresholdTokens(contextWindow, settings); const graceCapTokens = Math.min( thresholdTokens + resolveSpeculationLeadTokens(thresholdTokens), contextWindow - SPECULATION_LEAD_MIN_TOKENS, ); if (contextTokens >= graceCapTokens) return false; const run = this.#speculation; if (run) { if (run.armed) { if (!this.#armedSpeculationValid(run.armed, contextTokens)) { this.cancelSpeculation(); return false; } return false; // ready — the real pass splices it in now } return true; // still summarizing in the background } this.#startSpeculationRun(contextTokens, method); return true; } /** Produce and arm one speculative compaction result off a branch snapshot. */ async #runSpeculation( run: SpeculationRun, method: "remote" | "handoff" | "soft", contextTokens: number, ): Promise { const clear = () => { if (this.#speculation === run) this.#speculation = undefined; }; const model = this.#model; if (!model) return clear(); const settings = this.#compactionSettings; const effectiveSettings = resolveMethodSettings(settings, method); const branch = this.#host.sessionManager.getBranch(); const snapshotLeafId = branch[branch.length - 1]?.id; if (!snapshotLeafId) return clear(); const preparation = prepareCompaction(branch, effectiveSettings, model, this.#tokenizer); if (!preparation) return clear(); const signal = run.controller.signal; let armed: ArmedSpeculation; if (method === "handoff") { const generated = await this.#host.generateHandoffDocument(AUTO_HANDOFF_THRESHOLD_FOCUS, { autoTriggered: true, signal, }); if (!generated) return clear(); const { summary, details } = handoffSummaryFromDocument(generated.document, preparation); armed = { result: { summary, shortSummary: undefined, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, details, }, action: "handoff", method, snapshotLeafId, contextTokensAtStart: contextTokens, }; } else { const compactionPrep = await this.#prepareCompactionFromHooks(preparation, undefined, signal); // No hookCompaction is passed above, so "fromHook" is unreachable; // the guard just narrows the union. if (compactionPrep.kind === "fromHook") return clear(); const candidates = this.#getCompactionModelCandidates( this.#host.modelRegistry.getAvailable(), method === "remote" && !effectiveSettings.remoteEndpoint ? candidate => canUseLiveProviderNativeCompaction(candidate, model, effectiveSettings) : undefined, ); if (candidates.length === 0) return clear(); const codexCompaction = createCodexCompactionContext({ trigger: "auto", reason: "context_limit", phase: "standalone_turn", }); const result = await this.#compactWithFallbackModel( preparation, undefined, signal, { promptOverride: this.#host.obfuscateTextForProvider(compactionPrep.hookPrompt), extraContext: compactionPrep.hookContext, loadExtraContext: compactionPrep.loadMemoryContext, remoteSystemPrompt: this.#host.agent.state.systemPrompt, codexCompaction, // Isolate from the live turn: remote compaction transports key // sticky provider sessions by sessionId, and a speculation // overlapping the live stream must never interleave with it. sessionId: `${this.#host.sessionId()}:spec:${Snowflake.next()}`, preferWebsockets: false, }, candidates, ); armed = { result: { ...result, preserveData: mergeLlmCompactionPreserveData(compactionPrep.preserveData, result.preserveData), }, action: method === "remote" ? "remote" : "context-full", method, codexCompaction, snapshotLeafId, contextTokensAtStart: contextTokens, }; } if (signal.aborted || this.#speculation !== run) return; run.armed = armed; logger.debug("Speculative compaction armed", { method, snapshotLeafId, tokensBefore: armed.result.tokensBefore, }); } /** * An armed result is committable only when the branch prefix it summarized * is still intact: its snapshot leaf is on the active path with no later * compaction or reset boundary, and any provider-native replay payload is * still readable by the active model. * * When the branch has grown past the snapshot leaf, the speculation is only * valid if applying it would still create sufficient headroom under the * recovery band without net context expansion. */ #armedSpeculationValid( armed: ArmedSpeculation, triggerContextTokens?: number, pendingContextTokens?: number, ): boolean { const model = this.#model; if (!model) return false; const settings = this.#compactionSettings; if ( armed.result.preserveData && !remotePreserveReusable(armed.result.preserveData, model, resolveMethodSettings(settings, armed.method)) ) { return false; } const branch = this.#host.sessionManager.getBranch(); const leafIdx = branch.findIndex(entry => entry.id === armed.snapshotLeafId); if (leafIdx < 0) return false; for (let i = leafIdx + 1; i < branch.length; i++) { const type = branch[i].type; if (type === "compaction" || type === "reset_boundary") return false; } const nativeAnthropic = isRecord(armed.result.preserveData?.anthropicCompaction); if (leafIdx < branch.length - 1) { const keptIdx = branch.findIndex(entry => entry.id === armed.result.firstKeptEntryId); if (keptIdx < 0 && !(nativeAnthropic && armed.result.firstKeptEntryId === "")) return false; } const projected = this.#projectCompactedContextTokens({ ...armed.result, method: armed.method, providerReplayThroughEntryId: armed.result.providerReplayThroughEntryId ?? (isRecord(armed.result.preserveData?.openaiRemoteCompaction) || (nativeAnthropic && leafIdx < branch.length - 1) ? armed.snapshotLeafId : undefined), }); const branchCurrentTokens = this.#projectCurrentBranchContextTokens(branch); const nonMessageTokens = computeNonMessageTokens( this.#host.nonMessageTokenSource(), this.#tokenizer, this.#host.settings.revision, ); const emptyWrapperTokens = this.#countProjectedMessages([ createCompactionSummaryMessage("", 0, new Date(0).toISOString(), { method: armed.method }), ]); const maxArmedBaseline = Math.max( branchCurrentTokens, armed.contextTokensAtStart, armed.result.tokensBefore, triggerContextTokens ?? 0, ); if (maxArmedBaseline > emptyWrapperTokens && projected >= maxArmedBaseline) { return false; } if (branchCurrentTokens - nonMessageTokens > emptyWrapperTokens && projected >= branchCurrentTokens) { return false; } if (leafIdx < branch.length - 1 && !nativeAnthropic) { const pendingTokens = Math.max(0, pendingContextTokens ?? 0); const projectedWithPending = projected + pendingTokens; const contextWindow = model.contextWindow ?? 0; if (contextWindow > 0) { const thresholdTokens = resolveThresholdTokens(contextWindow, settings); const recoveryBand = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND); if (projectedWithPending > recoveryBand) { return false; } } // Anchor net-expansion check on local stored token count (same basis // as `projected`) to avoid basis mismatch against provider-billed tokens. // Also ensure projected tokens do not exceed the trigger context if provided. const storedCurrentTokens = this.#estimateStoredContextTokens(); const storedCurrentTokensWithPending = storedCurrentTokens + pendingTokens; if (storedCurrentTokens > 0 && projectedWithPending >= storedCurrentTokensWithPending) { return false; } if ( triggerContextTokens !== undefined && triggerContextTokens > 0 && projectedWithPending >= triggerContextTokens ) { return false; } } return true; } /** * Consume the speculation slot for a real maintenance pass. An in-flight run * is aborted (the real pass supersedes it); an armed result is returned only * when still valid for the current branch, model, and settings. */ #claimArmedSpeculation(triggerContextTokens?: number, pendingContextTokens?: number): ArmedSpeculation | undefined { const run = this.#speculation; if (!run) return undefined; this.#speculation = undefined; if (!run.armed) { run.controller.abort(); return undefined; } const settings = this.#compactionSettings; if (settings.asyncEnabled === false) return undefined; if (this.#host.extensionRunner?.hasHandlers("session_before_compact")) return undefined; if (!this.#armedSpeculationValid(run.armed, triggerContextTokens, pendingContextTokens)) { logger.debug("Armed speculative compaction invalidated by branch growth or headroom check", { method: run.armed.method, snapshotLeafId: run.armed.snapshotLeafId, }); return undefined; } return run.armed; } /** * Append a compaction entry and run the shared post-commit sequence: * rebuild the display context, swap live agent messages, re-anchor stats, * reset plan/advisor/todo runtime state derived from the replaced history, * reset provider sessions, and emit the `session_compact` extension hook. */ async #commitCompactionEntry(args: { summary: string; shortSummary: string | undefined; firstKeptEntryId: string; tokensBefore: number; details: unknown; fromExtension: boolean; preserveData: Record | undefined; method: CompactionMethod | undefined; codexCompaction: CodexCompactionContext | undefined; providerReplayThroughEntryId?: string; advisorResetReason: string; detachExtensionEmit?: boolean; }): Promise { const entryId = this.#host.sessionManager.appendCompaction( args.summary, args.shortSummary, args.firstKeptEntryId, args.tokensBefore, { details: args.details, fromExtension: args.fromExtension, preserveData: args.preserveData, method: args.method, providerReplayThroughEntryId: args.providerReplayThroughEntryId, tokensAfter: isRecord(args.details) && args.details.kind === "experimental-context-rollover" ? this.#projectExperimentalContextRolloverTokens(args) : this.#projectCompactedContextTokens(args), }, ); this.#host.persistEvalStateContext(); // A committed compaction starts a new cycle; native compaction gets a fresh try. this.#failedNativeSpeculation = undefined; const newEntries = this.#host.sessionManager.getEntries(); const sessionContext = this.#host.buildDisplaySessionContext(); this.#host.replaceMessagesFromSessionContext(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(args.advisorResetReason, { compacted: true }); this.#host.syncTodoPhasesFromBranch(); if (args.codexCompaction) { this.#host.resetCodexProviderAfterCompaction(args.codexCompaction); } else { this.#host.closeCodexProviderSessionsForHistoryRewrite(); } const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.id === entryId) as | CompactionEntry | undefined; if (this.#host.extensionRunner && savedCompactionEntry) { const compactEmit = this.#host.extensionRunner.emit({ type: "session_compact", compactionEntry: savedCompactionEntry, fromExtension: args.fromExtension, }); if (args.detachExtensionEmit) { void compactEmit.catch(error => { logger.warn("Detached session_compact emit failed", { error: error instanceof Error ? error.message : String(error), }); }); } else { await compactEmit; } } return savedCompactionEntry; } /** * 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[] = [], tokenizer = this.#tokenizer, messages: AgentMessage[] = this.#host.messages(), ): number { // Local counting is the whole point of this arm: provider usage is // exactly what it must not trust. Exclude encrypted reasoning // (thinkingSignature / redactedThinking) too — its local byte size // diverges from what the provider bills, so counting it would let a // thinking-heavy turn falsely trip the floor. The provider usage (the // other arm of compactionContextTokens) already accounts for it. // Native Responses reasoning that replays still counts, at its reported // reasoning tokens (see MessageCountOptions.excludeEncryptedReasoning). const opts = { excludeEncryptedReasoning: true } as const; return ( computeNonMessageTokens(this.#host.nonMessageTokenSource(), tokenizer, this.#host.settings.revision) + tokenizer.countMessages(messages, opts) + tokenizer.countMessages(pendingMessages, opts) ); } #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); } /** * Local pre-compaction context for a snapcompact preparation, measured on the * exact same non-message overhead and tokenizer as * {@link #projectSnapcompactContextTokens}: fixed overhead + the live * context's standing summary with its archive frames + the region that * would be archived (`messagesToSummarize` + `turnPrefixMessages`) + the * kept tail (`recentMessages`). Comparing the projection against this * self-consistent baseline isolates the single thing snapcompact changes — * the standing summary and the archived region become one imaged summary — so * a result whose frames cost more tokens than what they replace is rejected, * while genuine reductions are not. Recomputed from the live messages rather * than `preparation.tokensBefore`, which carries only provider usage (zero for * imported sessions with no usage metadata, or a deflated figure behind a * payload-shrinking hook) and would falsely reject reducing archives (#10023). * * Opaque provider-replay payloads (`thinkingSignature` / `redactedThinking`) * are excluded from every region: their local byte size is not a trustworthy * token price (#2628), and counting them in the archived region — which the * imaged projection drops — inflated this baseline enough to accept an * inflating result (#10716). The caller's reduction-side projection excludes * them symmetrically so the comparison stays in one local token domain. * Native Responses reasoning that replays is a provider-reported token * price, not a byte guess, so it stays in every region. */ #projectPreSnapcompactContextTokens(preparation: CompactionPreparation): number { const opts = { excludeEncryptedReasoning: true } as const; let tokens = computeNonMessageTokens( this.#host.nonMessageTokenSource(), this.#tokenizer, this.#host.settings.revision, ); // The live summary, not one rebuilt from the entry: a resumed session's // archive frames are blob references only the session manager resolves. // Context notes can precede it. const standingSummary = this.#replacesStandingSummary(preparation) ? this.#host.messages().find(message => message.role === "compactionSummary") : undefined; if (standingSummary) tokens += this.#tokenizer.countMessage(standingSummary); tokens += this.#tokenizer.countMessages(preparation.messagesToSummarize, opts); tokens += this.#tokenizer.countMessages(preparation.turnPrefixMessages, opts); tokens += this.#tokenizer.countMessages(preparation.recentMessages, opts); return tokens; } /** * Whether a new snapcompact archive replaces the standing summary. An archive * with frames but no source text (written before `Archive.text`) cannot be * re-rendered into the new one: its text edges carry over, its imaged middle * does not. Its summary is not counted as replaced: crediting it would let a * smaller candidate drop the only copy of that history. */ #replacesStandingSummary(preparation: CompactionPreparation): boolean { const previousArchive = snapcompact.getPreservedArchive(preparation.previousPreserveData); return !previousArchive || previousArchive.frames.length === 0 || previousArchive.text !== undefined; } async runPrePromptCompactionIfNeeded(messages: AgentMessage[]): Promise { const model = this.#model; if (!model) return; const contextWindow = model.contextWindow ?? 0; if (contextWindow <= 0) return; const compactionSettings = this.#compactionSettings; const contextTokens = this.#estimatePrePromptContextTokens(messages, contextWindow); const pendingMidTurnDeadEnd = this.#midTurnDeadEndPendingPrePrompt; this.#midTurnDeadEndPendingPrePrompt = undefined; if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) { this.maybeStartSpeculativeCompaction(contextTokens, contextWindow); return; } if (pendingMidTurnDeadEnd !== undefined) { const prep = prepareCompaction( this.#host.sessionManager.getBranch(), compactionSettings, model, this.#tokenizer, ); if ( prep === undefined || (pendingMidTurnDeadEnd !== null && prep.firstKeptEntryId === pendingMidTurnDeadEnd) ) { // The prior tool loop already attempted compaction/rescue at this // exact cut point and could not make progress. Only a later // persisted cut point makes a pre-prompt retry useful. return; } } // Grace band: a live (or just-started) background speculation absorbs the // blocking summarization; the user's prompt goes out immediately and the // armed result is spliced in at the next boundary. if (this.deferThresholdCompactionToSpeculation(contextTokens, contextWindow)) { logger.debug("Pre-prompt threshold deferred to speculative compaction", { contextTokens, contextWindow, }); 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, { autoContinue: false, triggerContextTokens: contextTokens, pendingContextTokens: this.#tokenizer.countMessages(messages, { excludeEncryptedReasoning: true }), preparedContextTokens: this.#estimateStoredContextTokens(), 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. */ 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.#compactionSettings; const experimentalNewContextRequest = this.#usesExperimentalContextManagement() && this.#host.takeExperimentalContextRolloverRequest(context); // An explicit model-requested rollover bypasses the Auto-Compact toggle: // `new_context` already acknowledged the request, so silently dropping it // here would leave the model believing a boundary landed. Automatic // threshold rollover remains gated by `compaction.enabled`. if ( (!compactionSettings.enabled && !experimentalNewContextRequest) || (!this.#usesExperimentalContextManagement() && !hasConfiguredCompactionMethod(compactionSettings)) || (!experimentalNewContextRequest && compactionSettings.midTurnEnabled === false) ) { return; } if (experimentalNewContextRequest) { if (!(await this.#host.persistTurnMessagesForMidRunCompaction(context))) return; // An abort or disposal that raced the awaited persistence barrier must // not commit a boundary the interrupted turn never observed. if (signal?.aborted || this.#host.isDisposed()) return; const result = await this.runAutoCompaction("threshold", false, { autoContinue: false, suppressContinuation: true, phase: "mid_turn", detachPostCommit: true, explicitNewContextRequest: true, }); if (result.automaticContinuationBlocked) { this.#midTurnCompactionDeadEnds.set(activeMessages, null); this.#midTurnDeadEndPendingPrePrompt = null; } if (signal?.aborted) return; const compactedMessages = this.#host.agent.state.messages; if (compactedMessages !== activeMessages) { activeMessages.splice(0, activeMessages.length, ...compactedMessages); invalidateConvertToLlmArrayCache(activeMessages); } return; } const lastAssistant = [...activeMessages] .reverse() .find((message): message is AssistantMessage => message.role === "assistant"); if (!lastAssistant || lastAssistant.stopReason === "aborted" || lastAssistant.stopReason === "error") return; // Decide from the live agent context before waiting for the asynchronous // session journal. Ordinary turns and grace-band deferrals do not await // persistence; rewrites and dead-end cut-point rechecks do. Awaiting it on every tool turn lets // a slow message_end listener leave the TUI "generating" with no provider // request or tool running. const billedContextTokens = calculateContextTokens(lastAssistant.usage); const storedContextTokens = this.#estimateStoredContextTokens(); const contextTokens = compactionContextTokens(billedContextTokens, storedContextTokens); const persistedDeadEnd = this.#midTurnCompactionDeadEnds.has(activeMessages); if (persistedDeadEnd) { if (!(await this.#host.persistTurnMessagesForMidRunCompaction(context))) return; if (signal?.aborted || this.#host.isDisposed()) return; // A prior boundary already ran maintenance/rescue and could not reduce // this turn. Re-running on every following tool boundary is wasted work // while no new cut point exists. Once a later tool turn advances the // cut point (`firstKeptEntryId`), compaction can summarize new history // and MUST re-arm rather than stay suppressed until provider overflow. const deadEndFirstKeptId = this.#midTurnCompactionDeadEnds.get(activeMessages); const prep = model ? prepareCompaction(this.#host.sessionManager.getBranch(), compactionSettings, model, this.#tokenizer) : undefined; if (prep === undefined || (deadEndFirstKeptId !== null && prep.firstKeptEntryId === deadEndFirstKeptId)) { return; } this.#midTurnCompactionDeadEnds.delete(activeMessages); this.#midTurnDeadEndPendingPrePrompt = undefined; } if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) { this.maybeStartSpeculativeCompaction(contextTokens, contextWindow); return; } // Grace band: keep the tool loop moving while a background speculation // (live or started here) produces the summary. Unless a prior dead end // needed its cut point rechecked, deferred boundaries never await the journal. if (this.deferThresholdCompactionToSpeculation(contextTokens, contextWindow)) { logger.debug("Mid-run threshold deferred to speculative compaction", { contextTokens, contextWindow, }); return; } if (!persistedDeadEnd && !(await this.#host.persistTurnMessagesForMidRunCompaction(context))) 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; const result = await this.runAutoCompaction("threshold", false, { autoContinue: false, suppressContinuation: true, triggerContextTokens: contextTokens, phase: "mid_turn", detachPostCommit: true, }); if (result.automaticContinuationBlocked) { const deadEndFirstKeptId = model ? (prepareCompaction(this.#host.sessionManager.getBranch(), compactionSettings, model, this.#tokenizer) ?.firstKeptEntryId ?? null) : null; this.#midTurnCompactionDeadEnds.set(activeMessages, deadEndFirstKeptId); this.#midTurnDeadEndPendingPrePrompt = deadEndFirstKeptId; } if (signal?.aborted) return; const compactedMessages = this.#host.agent.state.messages; if (compactedMessages !== activeMessages) { activeMessages.splice(0, activeMessages.length, ...compactedMessages); invalidateConvertToLlmArrayCache(activeMessages); } logger.debug("Mid-run compaction ran between provider calls", { contextTokens, contextWindow, methods: resolveCompactionMethodOrder(compactionSettings.methodOrder), 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 exhausted its output budget. Try promotion; otherwise compact and * retry when the context is over threshold (the window, not the output cap, * ran out), retry as-is when the turn produced nothing actionable, and * keep a truncated deliverable with a warning otherwise. * 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 autoContinue Whether maintenance may schedule the agent-authored continuation prompt. * @returns whether compaction/recovery scheduled a 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, 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(); // A delivered turn means the incomplete-recovery loop broke through: clear // the counter so a later isolated `length` stop starts fresh rather than // inheriting a stale count from an earlier loop. Signed reasoning alone does // not count, or a model burning every budget on thinking retries forever. if (assistantTurnDelivered(assistantMessage)) this.#incompleteRecoveryAttempts = 0; // 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(); const payloadRejection = sameModel && !errorIsFromBeforeCompaction && AIError.isPayloadRejection(assistantMessage); const ambiguousPayloadRejection = payloadRejection && AIError.is(assistantMessage.errorId, AIError.Flag.ContextOverflow); const storedTokens = payloadRejection && contextWindow > 0 ? this.#estimateStoredContextTokens() : 0; const reportedInputTokens = assistantMessage.usage.input + assistantMessage.usage.cacheRead + assistantMessage.usage.cacheWrite; const trustedPayloadRejection = payloadRejection && contextWindow > 0 && reportedInputTokens <= contextWindow && storedTokens < contextWindow * PAYLOAD_REJECTION_OCCUPANCY_CEILING; // Provider-reported usage above a known window is authoritative proof of a // genuine token overflow (not a byte/media-only rejection), computed once // up front so both the media-exclusion decision below and the terminal // dead-end check further down agree with each other. const usageBackedOverflow = AIError.isUsageBackedContextOverflow(assistantMessage, contextWindow); // Concrete media-limit wording (e.g. "image count exceeds the limit of 20") // in the error text — computed up front (independent of window or // compaction availability) so both the media-exclusion decision below and // the terminal dead-end check further down agree with each other. const explicitMediaRejection = payloadRejection && hasExplicitMediaRejectionEvidence(assistantMessage.errorMessage); // `payloadRejection` alone is not sufficient reason to exclude media // compaction methods (snapcompact): a *usage-backed* overflow proves the // rejection is a genuine token-context problem, not a byte/media budget // one, so a user configured with e.g. `methodOrder: ["snapcompact"]` must // still be able to use it instead of being told no recovery exists. // Explicit media-limit evidence overrides that exception, though: usage // proving a token overflow doesn't negate a provider *simultaneously* // reporting an image-count limit, and snapcompact adds image frames before // retrying — guaranteeing the retry stays over that limit (#11482). const excludeMediaForPayloadRejection = payloadRejection && (!usageBackedOverflow || explicitMediaRejection); // Whether a compaction method actually exists to attempt shrinking the // history. Computed up front so the unknown-context-window branch below // can fall through to a real attempt instead of always assuming defeat. // Uses the same reason/model-specific selection as `runAutoCompaction` // (not just a non-empty `methodOrder`) so an unusable-for-overflow // configuration — e.g. `methodOrder: ["handoff"]`, or `snapcompact`-only // on a text-only model — isn't reported as available and then silently // no-ops instead of surfacing the payload-rejection notice (#11482). // For a payload rejection specifically, `snapcompact` is excluded from // this availability check too: it archives history onto base64 image // frames, which only grows the byte size a payload/byte-limit 413 is // already complaining about (#11482). // (Named distinctly from the `compactionSettings` used further down in // this function's later, unrelated threshold check.) const payloadCompactionSettings = this.#compactionSettings; const payloadModel = this.#model; const compactionAvailable = payloadCompactionSettings.enabled && (this.#usesExperimentalContextManagement() || hasUsableCompactionMethod( "overflow", payloadModel, payloadCompactionSettings, excludeMediaForPayloadRejection, )); // A cut point inside the failing turn is not older reclaimable history. // Require a delivered assistant turn in the part being summarized; // shake selects its own victims and has a no-progress guard. const payloadBranch = trustedPayloadRejection ? this.#host.sessionManager.getBranch() : undefined; const reclaimablePayloadHistory = trustedPayloadRejection && compactionAvailable && !explicitMediaRejection && payloadModel !== undefined && payloadBranch?.some( entry => entry.type === "message" && entry.message.role === "assistant" && entry.message.stopReason !== "error", ) === true && (this.#usesExperimentalContextManagement() || resolveCompactionMethodOrder(payloadCompactionSettings.methodOrder).some(candidate => { if (!isCompactionMethodUsable(candidate, "overflow", payloadModel, payloadCompactionSettings, true)) return false; if (candidate === "shake") return true; const preparation = prepareCompaction( payloadBranch, resolveMethodSettings(payloadCompactionSettings, candidate), payloadModel, this.#tokenizer, ); return ( preparation?.messagesToSummarize.some( message => message.role === "assistant" && message.stopReason !== "error", ) === true ); })); const unknownWindowDeadEnd = payloadRejection && contextWindow <= 0 && !ambiguousPayloadRejection && !compactionAvailable; // Explicit media evidence is a terminal signal independent of whether the // context window is known: a known window's `trustedPayloadRejection` only // requires local headroom, so a message like "too many images" can still // fail that check (e.g. the local estimate is near the occupancy ceiling) // and fall through to promotion/compaction despite proving an image-count // limit neither can raise. Gated to `!usageBackedOverflow` though: when // provider-reported usage *also* proves a genuine token overflow, that's a // real, separately fixable problem — compaction should still get a shot at // it (with media methods excluded per `excludeMediaForPayloadRejection` // above), not be discarded just because the same response also named a // media limit (#11482). if ( unknownWindowDeadEnd || (trustedPayloadRejection && !reclaimablePayloadHistory) || (explicitMediaRejection && !usageBackedOverflow) ) { // None of these branches has usage-backed overflow evidence; the // provider's byte/media limit can still be hit with local token headroom. this.#host.removeAssistantMessageFromActiveContext(assistantMessage); this.#host.retainTerminalFailure(assistantMessage); this.#host.emitNotice("warning", payloadRejectionNotice(storedTokens, contextWindow), "compaction"); logger.debug("Payload-shaped 413 withheld from token compaction", { provider: assistantMessage.provider, model: assistantMessage.model, message: assistantMessage.errorMessage, storedTokens, contextWindow, }); return COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION; } const overflowEvidence = sameModel && !errorIsFromBeforeCompaction && AIError.isContextOverflow(assistantMessage, contextWindow); if (overflowEvidence || reclaimablePayloadHistory || (payloadRejection && !trustedPayloadRejection)) { this.#host.removeAssistantMessageFromActiveContext(assistantMessage); // A larger token window cannot raise a trusted byte-size limit. const promoted = !trustedPayloadRejection && (await this.#tryContextPromotion(assistantMessage)); if (promoted) { await this.#host.dropPersistedAssistantTurn(assistantMessage); // Retry on the promoted (larger) model without compacting this.#host.scheduleAgentContinue({ source: "context-promotion-overflow", delayMs: 100, generation, }); return COMPACTION_CHECK_CONTINUATION; } // No promotion target available fall through to compaction if (compactionAvailable) { const compactionResult = await this.#host.runRecoveryCompactionWithRollback("overflow", assistantMessage, { autoContinue, excludeMediaMethods: excludeMediaForPayloadRejection, }); // A statically usable method (per `hasUsableCompactionMethod`) can still // reclaim nothing at runtime — e.g. `methodOrder: ["shake"]` with no // heavy/droppable content on a plain text history — or `runAutoCompaction` // itself can already return `automaticContinuationBlocked` without a // rewrite (e.g. a single oversized latest turn `prepareCompaction` can't // shrink). Either way, when a payload rejection sees no history rewrite // and no scheduled continuation, `runRecoveryCompactionWithRollback` has // already restored the failed turn into active context (and, unless // already blocked, an unconverted no-op result here has neither a // scheduled continuation nor a block: the caller would treat it as an // ordinary turn end and could resubmit the same oversized history on the // next auto-continue, looping the same 413 silently with no notice ever // shown) (#11482). if ( payloadRejection && !compactionResult.continuationScheduled && compactionResult.historyRewritten !== true ) { // `runRecoveryCompactionWithRollback`'s no-rewrite path re-appends the // failed turn to active context (so it isn't silently lost) before // returning here. Unlike the immediate and no-method dead ends above/ // below, this branch discovers the no-progress outcome only *after* // that restoration, so it must re-remove the turn itself — otherwise // the next prompt's pre-prompt maintenance finds the same error via // `#findLastAssistantMessage()` and repeats this entire no-progress // compaction + warning before accepting new input (#11482). The // persisted session history (separate from this active-context view) // still keeps the turn visible. this.#host.removeAssistantMessageFromActiveContext(assistantMessage); this.#host.retainTerminalFailure(assistantMessage); if (compactionResult.automaticContinuationBlocked === true) { // `runAutoCompaction` already blocked and emitted its own notice for // this outcome (e.g. its own compaction dead end) — only the cleanup // above was missing; forward its result unchanged (#11482). return compactionResult; } // Same usage-backed/byte-shaped notice selection as the sibling // "no compaction available" dead end below: a payload rejection // with provider-reported usage above the window IS a genuine // token-context problem even though compaction (attempted here, // unsuccessfully) couldn't resolve it — say so accurately instead // of always claiming "not a token problem" (#11482). this.#host.emitNotice( "warning", usageBackedOverflow ? usageOverflowDeadEndNotice(reportedInputTokens, contextWindow) : payloadRejectionNotice(storedTokens, contextWindow), "compaction", ); logger.debug("Payload-shaped 413 compaction attempt made no progress; blocking automatic continuation", { provider: assistantMessage.provider, model: assistantMessage.model, message: assistantMessage.errorMessage, storedTokens, contextWindow, }); return COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION; } // No retry replaces the failed turn, so it stays the run's outcome for // post-settle readers (print mode, task executor) instead of whatever // assistant message preceded it. if (!compactionResult.continuationScheduled) { this.#host.retainTerminalFailure(assistantMessage); } return compactionResult; } if (payloadRejection) { this.#host.retainTerminalFailure(assistantMessage); this.#host.emitNotice( "warning", usageBackedOverflow ? usageOverflowDeadEndNotice(reportedInputTokens, contextWindow) : payloadRejectionNotice(storedTokens, contextWindow), "compaction", ); logger.debug("Payload-shaped 413 has no runnable recovery; blocking automatic continuation", { provider: assistantMessage.provider, model: assistantMessage.model, message: assistantMessage.errorMessage, storedTokens, contextWindow, }); return COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION; } this.#host.retainTerminalFailure(assistantMessage); 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 && cfgContextPromotionEnabled.get(this.#host.settings) ) { const foundModel = this.#host.modelRegistry.find(assistantMessage.provider, assistantMessage.model); const failedModel = foundModel && this.#host.modelRegistry.fitContextWindow(foundModel, this.#host.settings); 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({ source: "promoted-model-overflow", delayMs: 100, generation, }); return COMPACTION_CHECK_CONTINUATION; } } // Case 3: Output-side incomplete — `response.incomplete` from OpenAI Responses // (and Codex), Anthropic `max_tokens` / `model_context_window_exceeded`, all // map to stopReason === "length". Promotion if available; compaction only // when the window is what ran out, since shrinking the input cannot raise an // output cap. Unlike overflow, the *input* is fine, so a reachable handoff // preference may run. if (sameModel && !errorIsFromBeforeCompaction && assistantMessage.stopReason === "length") { const incompleteCompactionSettings = this.#compactionSettings; const incompleteContextTokens = calculateContextTokens(assistantMessage.usage); // The cut output is dropped before the next request, so only the prompt // counts against the configured trigger (#15870). Subtract the output from // occupancy rather than reading prompt buckets: an authoritative // `usage.contextTokens` (Cursor checkpoints) already includes this turn's // output. Prompt + output still marks the window itself as what ran out // (e.g. Anthropic `model_context_window_exceeded`), measured against the // window's own prompt budget rather than a trigger the user may have set // far lower. const incompletePromptTokens = Math.max(0, incompleteContextTokens - assistantMessage.usage.output); // Unknown windows keep compacting: there is no evidence the window had room. const windowExhausted = contextWindow <= 0 || incompletePromptTokens > resolveThresholdTokens(contextWindow, incompleteCompactionSettings) || incompleteContextTokens > promptBudgetTokens(contextWindow, incompleteCompactionSettings); if (!windowExhausted && assistantTurnDelivered(assistantMessage)) { // The output cap truncated a real deliverable with the window still open: // neither a larger window nor compaction buys output room, and a retry // would regenerate the same truncation. Keep it and let the user steer. logger.warn("response.incomplete: output cap reached below compaction threshold; keeping truncated turn", { model: `${assistantMessage.provider}/${assistantMessage.model}`, promptTokens: incompletePromptTokens, outputTokens: assistantMessage.usage.output, }); this.#host.emitNotice( "warning", `Response hit the ${assistantMessage.provider}/${assistantMessage.model} output limit (${assistantMessage.usage.output} tokens) and was truncated.`, "compaction", ); return COMPACTION_CHECK_NONE; } // 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); this.#incompleteRecoveryAttempts = 0; logger.debug("Context promotion triggered by response.incomplete (length stop)", { from: `${assistantMessage.provider}/${assistantMessage.model}`, }); this.#host.scheduleAgentContinue({ source: "context-promotion-incomplete", delayMs: 100, generation, }); return COMPACTION_CHECK_CONTINUATION; } if ( incompleteCompactionSettings.enabled && (this.#usesExperimentalContextManagement() || hasConfiguredCompactionMethod(incompleteCompactionSettings)) ) { // Bound the loop: a model that keeps returning an empty `length` turn // (zero usage, no content) re-triggers compaction + shake-retry forever // otherwise, persisting an empty assistant turn each pass (#10594). Count // no-progress incomplete recoveries; past the cap, drop the dead turn, // surface an actionable error, and stop scheduling continuations. Any // turn that produced actionable output already reset the counter at the // top of checkCompaction, so a genuinely truncated turn that resumes // after compaction is never cut short. if (this.#incompleteRecoveryAttempts >= INCOMPLETE_RECOVERY_MAX_RETRIES) { const attempts = this.#incompleteRecoveryAttempts; this.#incompleteRecoveryAttempts = 0; const droppedEntryId = await this.#host.dropPersistedAssistantTurn(assistantMessage); // Reparenting the live branch is insufficient when this terminal path // appends no successor: on restart, the loader selects the last physical // journal entry and revives the discarded length turn. Persist the branch // marker/rewrite before blocking further continuation. if (droppedEntryId) await this.#host.sessionManager.discardEntryDurably(droppedEntryId); const finalError = `Length-stop recovery gave up after ${attempts} consecutive \`length\` responses from ${assistantMessage.provider}/${assistantMessage.model} with no text or tool call. Try switching models or raising the model's max output tokens.`; logger.warn("response.incomplete recovery cap reached; halting retries", { model: `${assistantMessage.provider}/${assistantMessage.model}`, attempts, }); this.#host.emitNotice("error", finalError, "compaction"); // Without this the dropped turn leaves no error behind, so the task // executor reads the run as idle and re-prompts it into the same loop. this.#host.retainTerminalFailure({ ...assistantMessage, stopReason: "error", errorMessage: finalError }); return COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION; } this.#incompleteRecoveryAttempts++; if (!windowExhausted) { // Nothing delivered and the window still has room: compaction would // only rewrite history the next attempt does not need shrunk. await this.#host.dropPersistedAssistantTurn(assistantMessage); // The dropped turn spent the output cap on reasoning. Re-sending the // same context re-runs the same plan into the same cap, so tell the // model what happened and to act in smaller steps. if (assistantMessage.usage.output > 0) { this.#host.agent.appendMessage({ role: "developer", content: [ { type: "text", text: prompt.render(lengthStopRetryTemplate, { outputTokens: assistantMessage.usage.output, retryCount: this.#incompleteRecoveryAttempts, maxRetries: INCOMPLETE_RECOVERY_MAX_RETRIES, }), }, ], attribution: "agent", timestamp: Date.now(), }); } logger.debug("Retrying response.incomplete without compaction (below threshold)", { model: `${assistantMessage.provider}/${assistantMessage.model}`, promptTokens: incompletePromptTokens, attempt: this.#incompleteRecoveryAttempts, }); this.#host.scheduleAgentContinue({ source: "incomplete-retry", delayMs: 100, generation }); return COMPACTION_CHECK_CONTINUATION; } logger.debug("Compaction triggered by response.incomplete (length stop, no promotion target)", { model: `${assistantMessage.provider}/${assistantMessage.model}`, methods: resolveCompactionMethodOrder(incompleteCompactionSettings.methodOrder), attempt: this.#incompleteRecoveryAttempts, }); return await this.#host.runRecoveryCompactionWithRollback("incomplete", assistantMessage, { 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 = this.#usesExperimentalContextManagement() ? undefined : await this.#pruneStaleToolResults(); const compactionSettings = this.#compactionSettings; if ( !compactionSettings.enabled || (!this.#usesExperimentalContextManagement() && !hasConfiguredCompactionMethod(compactionSettings)) ) 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 = this.#usesExperimentalContextManagement() ? undefined : 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, methods: resolveCompactionMethodOrder(compactionSettings.methodOrder), thresholdTokens, assistantUsageContextTokens, storedContextTokens, resolvedContextTokens: contextTokens, postMaintenanceContextTokens, maintenanceTokensFreed, shouldCompact: shouldThresholdCompact, contextPromotionEnabled: cfgContextPromotionEnabled.get(this.#host.settings) === true, }); if (shouldThresholdCompact) { // Grace band: a live (or just-started) background speculation absorbs // the blocking summarization; the session stays responsive and the // armed result lands at the next boundary. Deferral delays promotion // by at most the band — the eventual real pass still promotes first. if (this.deferThresholdCompactionToSpeculation(postMaintenanceContextTokens, contextWindow)) { logger.debug("Post-turn threshold deferred to speculative compaction", { postMaintenanceContextTokens, contextWindow, }); return COMPACTION_CHECK_NONE; } // 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, { 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}`, }); } else { this.maybeStartSpeculativeCompaction(contextTokens, contextWindow); } 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 { if (!cfgContextPromotionEnabled.get(this.#host.settings) || this.#host.modelFallbackDisabled?.()) 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 configured = resolveContextPromotionConfiguredTarget(currentModel, availableModels); // Judge the window this session would actually run the target with. const candidate = configured && this.#host.modelRegistry.fitContextWindow(configured, this.#host.settings); if (!candidate) return undefined; if (modelsAreEqual(candidate, currentModel)) return undefined; if (candidate.contextWindow == null || candidate.contextWindow <= contextWindow) return undefined; // A larger window only helps when the target reads this same context. One // that cannot replay the compaction it stands on reads the history behind // it instead, so promote only when that history fits too. const branch = this.#host.sessionManager.getBranch(); if ( getReadableCompactionEntry(branch, candidate) !== getReadableCompactionEntry(branch, currentModel) && !this.contextFitsModel(candidate) ) { 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[] { // Shared catalog rows carry the registry's extended-window opt-ins; judge // and compact with the window this session's settings select. const registry = this.#host.modelRegistry; const settings = this.#host.settings; availableModels = availableModels.map(model => registry.fitContextWindow(model, settings)); 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 CHAT_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()); let nativeCompactionFailure: { error: NativeCompactionError; provider: string } | undefined; for (const candidate of candidates) { 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; } 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), buildProviderContext: (summarized, retained, signal, model) => this.#host.buildLiveProviderContext(summarized, retained, signal, model), isUserAuthored: isUserAuthoredMessage, onPayload: this.#host.extensionRunner?.hasHandlers("before_provider_request") ? (payload, model, signal) => this.#host.extensionRunner?.emitBeforeProviderRequest(payload, model, signal) : undefined, 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(), // Explicit cache keys only (never a sessionId fallback): V2 // treats any truthy key as provenance for x-opencode-session. // Conventional summaries run on `candidate`; remote compaction // may send a different api/model, so it is gated separately. promptCacheKey: this.#host.getPromptCacheKeyForModel(candidate), remotePromptCacheKey: this.#host.getPromptCacheKeyForModel(remoteCompactionPolicyModel(candidate)), providerSessionState: this.#host.providerSessionState, preferWebsockets: this.#host.preferWebsockets(candidate), codexAccessPrograms: cfgProvidersOpenaiCodexDaybreak.get(this.#host.settings), // 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) { const id = AIError.classify(error instanceof NativeCompactionError ? error.cause : error, candidate.api); if (AIError.is(id, AIError.Flag.AuthFailed)) continue; if (error instanceof NativeCompactionError) { nativeCompactionFailure ??= { error, provider: candidate.provider }; continue; } throw error; } } if (nativeCompactionFailure) throw nativeCompactionFailure.error; throw this.#buildCompactionAuthError(); } async #prepareCompactionFromHooks( preparation: CompactionPreparation, hookCompaction: CompactionResult | undefined, /** Aborts backend recall when compaction is cancelled or superseded. */ signal: AbortSignal, ): Promise< | { kind: "fromHook"; summary: string; shortSummary: string | undefined; firstKeptEntryId: string; tokensBefore: number; details: unknown; preserveData: Record | undefined; providerReplayThroughEntryId: string | undefined; } | { kind: "needsLlm"; hookContext: string[] | undefined; loadMemoryContext: () => Promise; 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; } if (hookCompaction) { preserveData ??= hookCompaction.preserveData; return { kind: "fromHook", summary: hookCompaction.summary, shortSummary: hookCompaction.shortSummary, firstKeptEntryId: hookCompaction.firstKeptEntryId, tokensBefore: hookCompaction.tokensBefore, details: hookCompaction.details, preserveData, providerReplayThroughEntryId: hookCompaction.providerReplayThroughEntryId, }; } let memoryContext: Promise | undefined; return { kind: "needsLlm", hookContext, hookPrompt, preserveData, loadMemoryContext: () => (memoryContext ??= this.#collectMemoryBackendContext(preparation, signal).then(context => context ? [context] : undefined, )), }; } /** * Cap on snapcompact frames, sized from the room under the compaction * trigger rather than the window: {@link SNAPCOMPACT_ARCHIVE_SHARE} of * `threshold − (system prompt + tools + kept recent + cap reserve)`, never * past {@link SNAPCOMPACT_POST_COMPACTION_TARGET} of the threshold, and never * past what the post-compaction context can carry without busting the model * window. Charges each frame {@link snapcompact.plannedFrameTokens}, never * less than the per-frame price the tokenizer will bill, 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: EngineCompactionSettings, pendingTokens: number, ): number { const ctxWindow = this.#model?.contextWindow ?? 0; const shape = snapcompact.resolveShape(this.#model, cfgSnapcompactShape.get(this.#host.settings)); if (ctxWindow <= 0) { return Math.min( snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget(shape, snapcompact.frameDataBytesBudget(this.#model)), snapcompact.providerFrameBudget(this.#model), ); } const reserve = effectiveReserveTokens(ctxWindow, settings); const nonMessageTokens = computeNonMessageTokens( this.#host.nonMessageTokenSource(), this.#tokenizer, this.#host.settings.revision, ); const baseTokens = nonMessageTokens + this.#tokenizer.countMessages(preparation.recentMessages); 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 `countMessage(summaryMessage)` will charge // when frames > 0: `countTokens(summaryTemplate ‖ textHead ‖ textTail)` // plus each frame at the reading model's frame price. 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 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 thresholdTokens = resolveThresholdTokens(ctxWindow, settings); // The trigger counts kept turns without opaque reasoning replay bytes and // sees the pending prompt too; the window fit keeps the full count. const fixedTokens = nonMessageTokens + this.#tokenizer.countMessages(preparation.recentMessages, { excludeEncryptedReasoning: true }) + pendingTokens + capReserve; const roomBudget = Math.min( SNAPCOMPACT_ARCHIVE_SHARE * (thresholdTokens - fixedTokens), SNAPCOMPACT_POST_COMPACTION_TARGET * thresholdTokens - fixedTokens, ); const frameBudget = Math.min(totalBudget - baseTokens - capReserve, roomBudget); const frameCost = snapcompact.plannedFrameTokens(shape); if (roomBudget < frameCost) { logger.warn("Snapcompact archive has no room under the compaction trigger", { model: this.#model?.id, thresholdTokens, fixedTokens, }); } if (frameBudget < frameCost) return 1; return Math.min( Math.floor(frameBudget / frameCost), snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget(shape, snapcompact.frameDataBytesBudget(this.#model)), snapcompact.providerFrameBudget(this.#model), ); } #snapcompactFramePayloadBytes(result: snapcompact.CompactionResult): number { const archive = snapcompact.getPreservedArchive(result.preserveData); return archive ? snapcompact.frameDataBytes(archive.frames) : 0; } /** What the active tokenizer charges for `archive`'s frames inside a compaction summary. */ #snapcompactArchiveFrameTokens(archive: snapcompact.Archive): number { const timestamp = new Date(0).toISOString(); const blocks = archive.frames.map(frame => ({ type: "image" as const, data: frame.data, mimeType: "image/png" })); return ( this.#tokenizer.countMessage(createCompactionSummaryMessage("", 0, timestamp, { blocks })) - this.#tokenizer.countMessage(createCompactionSummaryMessage("", 0, timestamp)) ); } /** * Render the snapcompact archive at `options.maxFrames`, then re-render it * with the oldest frames dropped while it breaks either budget, at most * twice (the second pass absorbs per-frame variance): * - {@link snapcompact.frameDataBytesBudget} for the active model: the cap from * {@link #computeSnapcompactMaxFrames} sizes bytes from a per-shape * estimate, and denser frames (CJK prose drawn with fallback glyphs) run * heavier, so the fit is `frames × budget / payload`; * - {@link SNAPCOMPACT_POST_COMPACTION_TARGET} of the threshold, counting * `pendingTokens` (a pre-prompt pass's prompt, not yet in the session): * the cap reserves text from an estimate that can run short, so the fit * drops the measured excess at the archive's mean frame price. * A single frame cannot shrink further, so 60% is a sizing target, not a * guarantee. The caller still rejects a payload that stays over budget, and * passes that need the recovery band reject a context above it * ({@link #snapcompactLeavesNoHeadroom}). */ async #renderSnapcompactArchive( preparation: CompactionPreparation, options: snapcompact.Options, settings: EngineCompactionSettings, pendingTokens: number, ): Promise<{ result: snapcompact.CompactionResult; framePayloadBytes: number }> { let result = await snapcompact.compact(preparation, options); let framePayloadBytes = this.#snapcompactFramePayloadBytes(result); const ctxWindow = this.#model?.contextWindow ?? 0; const frameByteBudget = snapcompact.frameDataBytesBudget(this.#model); const targetTokens = ctxWindow > 0 ? Math.floor(SNAPCOMPACT_POST_COMPACTION_TARGET * resolveThresholdTokens(ctxWindow, settings)) : Number.POSITIVE_INFINITY; for (let rerender = 0; rerender < 2; rerender++) { const archive = snapcompact.getPreservedArchive(result.preserveData); if (!archive) break; const frames = archive.frames.length; let maxFrames = frames; if (framePayloadBytes > frameByteBudget) { maxFrames = Math.floor((frames * frameByteBudget) / framePayloadBytes); } const projected = this.#projectSnapcompactContextTokens(preparation, result, { excludeEncryptedReasoning: true }) + pendingTokens; if (projected > targetTokens && frames > 1) { const perFrame = this.#snapcompactArchiveFrameTokens(archive) / frames; maxFrames = Math.min(maxFrames, Math.max(1, frames - Math.ceil((projected - targetTokens) / perFrame))); } if (maxFrames < 1 || maxFrames >= frames) break; logger.debug("Snapcompact re-rendering the archive at fewer frames", { model: this.#model?.id, frames, framePayloadBytes, projected, targetTokens, maxFrames, }); result = await snapcompact.compact(preparation, { ...options, maxFrames }); framePayloadBytes = this.#snapcompactFramePayloadBytes(result); } return { result, framePayloadBytes }; } /** * Whether a rendered archive (plus a pre-prompt pass's pending prompt) * would leave the compacted context above * `COMPACTION_RECOVERY_BAND × threshold`, the band * {@link #compactionCreatedHeadroom} requires after a threshold pass. * Committing such an archive there only re-enters the no-headroom rescue, so * the pass rejects it and lets the next configured method try; with no later * usable summarizing method it keeps an archive that still fits the window. Overflow and * incomplete recovery retry the turn and need only the window fit * ({@link #compactionCreatedRetryFit}), idle passes check neither, and manual * `/compact` keeps its window-fit check: the user asked for it. */ #snapcompactLeavesNoHeadroom(projectedTokens: number, settings: EngineCompactionSettings): boolean { const ctxWindow = this.#model?.contextWindow ?? 0; if (ctxWindow <= 0) return false; return projectedTokens > Math.floor(resolveThresholdTokens(ctxWindow, settings) * COMPACTION_RECOVERY_BAND); } #deadEndRemedies(defaultRemedies: string, implicatedFrames: number): string { if (implicatedFrames <= 0) return defaultRemedies; return `reduce archived image frames (${implicatedFrames} held) — providers often bill vision media separately from tokens; ${defaultRemedies}`; } /** * 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, options?: MessageCountOptions, ): number { const archive = snapcompact.getPreservedArchive(result.preserveData); const blocks = archive ? snapcompact.historyBlocks(archive, { maxFrameDataBytes: snapcompact.frameDataBytesBudget(this.#model) }) : undefined; const summaryMessage = createCompactionSummaryMessage( result.summary, result.tokensBefore, new Date().toISOString(), { shortSummary: result.shortSummary, blocks, }, ); let tokens = computeNonMessageTokens(this.#host.nonMessageTokenSource(), this.#tokenizer, this.#host.settings.revision) + this.#tokenizer.countMessage(summaryMessage); tokens += this.#tokenizer.countMessages(preparation.recentMessages, options); return tokens; } /** * Estimated context tokens after a compaction commit. The projection uses the * same synthetic compaction boundary and `buildSessionContext` conversion as * the post-commit rebuild, so message-bearing custom entries and branch * summaries are counted alongside ordinary messages. The result is persisted * as `tokensAfter` on the entry so the transcript divider can show the before * → after amounts. */ #projectCompactedContextTokens(args: CompactionProjectionArgs): number { return this.#projectCompactionContextTokens(args); } #projectCompactionContextTokens(args: CompactionProjectionArgs): number { const nonMessageTokens = computeNonMessageTokens( this.#host.nonMessageTokenSource(), this.#tokenizer, this.#host.settings.revision, ); const branch = this.#host.sessionManager.getBranch(); const leaf = branch.at(-1); const archive = snapcompact.getPreservedArchive(args.preserveData); const blocks = archive ? snapcompact.historyBlocks(archive, { maxFrameDataBytes: snapcompact.frameDataBytesBudget(this.#model) }) : undefined; if (!leaf) { const summaryMessage = createCompactionSummaryMessage( args.summary, args.tokensBefore, new Date().toISOString(), { shortSummary: args.shortSummary, method: args.method, blocks, }, ); return nonMessageTokens + this.#countProjectedMessages([summaryMessage]); } const pending: CompactionEntry = { type: "compaction", id: `${leaf.id}:compaction-tokens-projection`, parentId: leaf.id, timestamp: new Date().toISOString(), summary: args.summary, shortSummary: args.shortSummary, firstKeptEntryId: args.firstKeptEntryId, tokensBefore: args.tokensBefore, method: args.method, details: args.details, preserveData: args.preserveData, providerReplayThroughEntryId: args.providerReplayThroughEntryId, }; const projected = [...branch, pending]; const rebuilt = buildSessionContext(projected, undefined, undefined, this.#contextBuildOptions); return nonMessageTokens + this.#countProjectedMessages(rebuilt.messages, projected); } /** * Count a rebuilt context the way {@link #estimateStoredContextTokens} will once it is committed: * a summary carrying frames is counted as itself so each frame gets the active model's frame * price, and an archive as the active model reads it ({@link #messagesAsRead}), found among * `entries` (the projected branch when the compaction is not committed yet). Native Responses * history replaces the display summary and excludes opaque ciphertext. */ #countProjectedMessages( rebuilt: AgentMessage[], entries: readonly SessionEntry[] = this.#host.sessionManager.getBranchView(), ): number { const messages = this.#messagesAsRead(rebuilt, entries); const options = { excludeEncryptedReasoning: true } as const; const archives: AgentMessage[] = []; const rest: AgentMessage[] = []; let nativeHistoryTokens = 0; for (const message of messages) { if (message.role === "compactionSummary" && message.providerPayload?.type === "openaiResponsesHistory") { nativeHistoryTokens += countResponsesHistoryTokens(message.providerPayload.items, this.#tokenizer); continue; } const hasFrames = message.role === "compactionSummary" && (message.blocks !== undefined || message.images !== undefined); (hasFrames ? archives : rest).push(message); } return ( nativeHistoryTokens + this.#tokenizer.countMessages(archives, options) + this.#tokenizer.countMessages(convertToLlm(rest), options) ); } /** * Current branch context tokens in the same `buildSessionContext` + * {@link #countProjectedMessages} domain as {@link #projectCompactionContextTokens}. */ #projectCurrentBranchContextTokens(branch: SessionEntry[] = this.#host.sessionManager.getBranch()): number { const nonMessageTokens = computeNonMessageTokens( this.#host.nonMessageTokenSource(), this.#tokenizer, this.#host.settings.revision, ); if (branch.length === 0) return nonMessageTokens; const current = buildSessionContext(branch, undefined, undefined, this.#contextBuildOptions); return nonMessageTokens + this.#countProjectedMessages(current.messages); } /** * Whether a candidate compaction genuinely shrinks the context. Checks both * the trigger/usage baseline (`effectiveTokensBefore`) and — whenever the * replaced branch region exceeds the fixed summary wrapper overhead or * already carries a previous summary — the local branch projection * (`#projectCurrentBranchContextTokens`). */ #compactionReducesContext( preparation: CompactionPreparation, branch: SessionEntry[], branchBefore: number, firstKeptEntryId: string, projectedAfter: number, effectiveTokensBefore: number, method?: CompactionMethod, ): boolean { const nonMessageTokens = computeNonMessageTokens( this.#host.nonMessageTokenSource(), this.#tokenizer, this.#host.settings.revision, ); const firstKeptIdx = firstKeptEntryId ? branch.findIndex(entry => entry.id === firstKeptEntryId) : -1; const keptEntries = firstKeptIdx >= 0 ? branch.slice(firstKeptIdx) : []; const keptContext = keptEntries.length > 0 ? buildSessionContext(keptEntries, undefined, undefined, this.#contextBuildOptions) : undefined; const keptTokens = keptContext ? this.#countProjectedMessages(keptContext.messages) : this.#countProjectedMessages(preparation.recentMessages); const replacedBranchTokens = Math.max(0, branchBefore - nonMessageTokens - keptTokens); const emptyWrapperTokens = this.#countProjectedMessages([ createCompactionSummaryMessage("", 0, new Date(0).toISOString(), { method }), ]); const hasRealReplacedHistory = replacedBranchTokens > emptyWrapperTokens || preparation.previousSummary !== undefined; const maxBaseline = Math.max(effectiveTokensBefore, branchBefore); if ((hasRealReplacedHistory || effectiveTokensBefore > emptyWrapperTokens) && projectedAfter >= maxBaseline) { return false; } if (hasRealReplacedHistory && projectedAfter >= branchBefore) { return false; } return true; } /** * Project `tokensAfter` for a notes-backed rollover from the same * reconstruction the commit will perform. The rollover details cause * `buildSessionContext` to retain the user-attributed request and injected * notebook in addition to the summary and kept tail. Pure projection: the * synthetic entry never reaches the journal, and the value remains a local * estimate, not provider-billed usage. */ #projectExperimentalContextRolloverTokens(args: CompactionProjectionArgs): number { return this.#projectCompactionContextTokens({ ...args, details: { kind: "experimental-context-rollover", version: 1 }, }); } #fixedContextExceedsRecoveryBand(): boolean { const contextWindow = this.#model?.contextWindow ?? 0; if (contextWindow <= 0) return false; const recoveryBand = Math.floor( resolveThresholdTokens(contextWindow, this.#compactionSettings) * COMPACTION_RECOVERY_BAND, ); return ( computeNonMessageTokens(this.#host.nonMessageTokenSource(), this.#tokenizer, this.#host.settings.revision) > recoveryBand ); } /** * When the system prompt and tool schemas alone exceed the recovery band, no * threshold pass can land under it, and re-running one every turn loops * (#18589). Returns the blocking result (warning once per session), or * `undefined` when compaction can still help. */ #thresholdFixedContextDeadEnd(): CompactionCheckResult | undefined { if (!this.#fixedContextExceedsRecoveryBand()) { this.#fixedContextDeadEndNotifiedSessionId = undefined; return undefined; } const sessionId = this.#host.sessionManager.getSessionId(); if (this.#fixedContextDeadEndNotifiedSessionId !== sessionId) { this.#host.emitNotice("warning", FIXED_CONTEXT_DEAD_END_WARNING, "compaction"); this.#fixedContextDeadEndNotifiedSessionId = sessionId; } return COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION; } #compactionDeadEndWarning(remedies: string): string { if (this.#fixedContextExceedsRecoveryBand()) return FIXED_CONTEXT_DEAD_END_WARNING; 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.` ); } /** * 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.#compactionSettings; 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; } /** * Whether the stored context, as `model` reads it, fits `model`'s usable window * (`contextWindow - reserve`), using the same reserve resolution as * compaction. This is deliberately independent of `compaction.enabled`: an * oversized request overflows the provider whether or not compaction would * have run, so a fit check must judge the raw budget. * * 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 callers do not enter headroom the user * intentionally reserved. * * Used by the retry-fallback selector to skip a candidate whose window cannot * hold the retry context before switching onto it, and (via * {@link #compactionCreatedRetryFit}) to decide whether an overflow recovery * produced a retryable prompt. `excludedMessage` identifies a failed assistant * turn that will be removed before retrying; subtracting it makes the selector * judge the request that will actually be sent. When the window is unknown we * cannot evaluate the budget, so we optimistically report a fit (preserving * prior behavior). A model that stands on another compaction boundary (see * `BuildSessionContextOptions.model`) is judged on its own rebuilt context, * so a switch never lands on re-expanded history its window cannot hold. */ contextFitsModel(model: Model, excludedMessage?: AssistantMessage): boolean { const contextWindow = model.contextWindow ?? 0; if (contextWindow <= 0) return true; const fitBudget = Math.max( 0, contextWindow - resolveBudgetReserveTokens(contextWindow, this.#compactionSettings), ); const messages = this.#host.messages(); const activeExcludedMessage = excludedMessage && messages.includes(excludedMessage) ? excludedMessage : undefined; // Provider usage describes the active model's last request, so the failed // turn comes off it at the active model's count. The local floor counts // what `model` itself would be sent: another reader drops the active // model's natively replayed reasoning, a cold session drops its own, and // a reader without image input gets each archive as text. const providerExcludedTokens = activeExcludedMessage ? this.#tokenizer.countMessage(activeExcludedMessage) : 0; const tokenizerOptions = { nativeHistoryReplay: openAIResponsesReplaysNativeHistory(model, this.#host.providerSessionState), }; const tokenizer = this.#tokenizer.countsAlike(model, tokenizerOptions) ? this.#tokenizer : new Tokenizer(model, tokenizerOptions); const storedExcludedTokens = activeExcludedMessage ? tokenizer.countMessage(activeExcludedMessage, { excludeEncryptedReasoning: true }) : 0; const branch = this.#host.sessionManager.getBranch(); if ( this.#model && getReadableCompactionEntry(branch, model) !== getReadableCompactionEntry(branch, this.#model) ) { // `model` stands on another compaction boundary than the active model // (it cannot replay a provider-native compaction, or can replay one the // active model cannot), so neither the provider usage nor the live // messages describe what it would be sent: count its own context. const readerMessages = this.#host.buildDisplaySessionContext(model).messages; // The rebuild can return copies (secret deobfuscation), so the failed turn also matches by what // it holds; at most one message is dropped, newest first, so an earlier reply sharing its // millisecond stays counted. const excludedIndex = activeExcludedMessage ? readerMessages.findLastIndex( message => message === activeExcludedMessage || (message.role === "assistant" && message.timestamp === activeExcludedMessage.timestamp && Bun.deepEquals(message.content, activeExcludedMessage.content)), ) : -1; const sentMessages = excludedIndex === -1 ? readerMessages : readerMessages.filter((_, index) => index !== excludedIndex); const readerTokens = computeNonMessageTokens(this.#host.nonMessageTokenSource(), tokenizer, this.#host.settings.revision) + tokenizer.countMessages(sentMessages, { excludeEncryptedReasoning: true }); return readerTokens <= fitBudget; } // Provider usage carries the reasoning the active model's request replayed, // so it moves into `model`'s domain by the difference in replayed // reasoning. Usage leaves out the anchor turn's own output, so this may // subtract reasoning it never held; the local floor below still bounds it. let replayedReasoningShift = 0; if (tokenizer !== this.#tokenizer) { const retained = activeExcludedMessage ? messages.filter(m => m !== activeExcludedMessage) : messages; replayedReasoningShift = tokenizer.replayedReasoningTokens(retained) - this.#tokenizer.replayedReasoningTokens(retained); } const residualTokens = compactionContextTokens( Math.max( 0, (this.#host.getContextUsage({ contextWindow })?.tokens ?? 0) - providerExcludedTokens + replayedReasoningShift, ), Math.max( 0, this.#estimateStoredContextTokens( [], tokenizer, this.#messagesAsRead(messages, this.#host.sessionManager.getBranchView(), model), ) - storedExcludedTokens, ), ); return residualTokens <= fitBudget; } /** * Retry-side check: whether an overflow/incomplete recovery rebuilt a prompt * that fits the active model's window again. Callers MUST invoke this AFTER * dropping the failed assistant from `this.#host.messages()` so the just-failed * turn (absent from the retry prompt) is excluded from the estimate. Unlike * the `COMPACTION_RECOVERY_BAND × threshold` hysteresis the auto-continue * thrash guard uses, a retry only needs to *fit* — a 200k-window prompt * compacted from overflow down to ~150k is retryable even though it sits above * `0.8 × 170k` (PR #3412 review). */ #compactionCreatedRetryFit(): boolean { return this.#model ? this.contextFitsModel(this.#model) : true; } /** * 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(), resolveMethodSettings(this.#compactionSettings, "snapcompact"), 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", { config: RESCUE_SHAKE_CONFIG, 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 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: EngineCompactionSettings, keptTailTokens: number): number { const ctxWindow = this.#model?.contextWindow ?? 0; const shape = snapcompact.resolveShape(this.#model, cfgSnapcompactShape.get(this.#host.settings)); if (ctxWindow <= 0) { return Math.min( snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget(shape, snapcompact.frameDataBytesBudget(this.#model)), snapcompact.providerFrameBudget(this.#model), ); } const thresholdTokens = resolveThresholdTokens(ctxWindow, settings); const recoveryBandTokens = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND); const baseTokens = computeNonMessageTokens( this.#host.nonMessageTokenSource(), this.#tokenizer, this.#host.settings.revision, ); 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; const frameCost = snapcompact.plannedFrameTokens(shape); if (frameBudget < frameCost) return 0; // Same hard caps as #computeSnapcompactMaxFrames: a threshold-derived // count above the per-request payload or provider image budget would // "shrink" a huge archive to a frame count the rebuilt prompt can never // attach anyway. return Math.min( Math.floor(frameBudget / frameCost), snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget(shape, snapcompact.frameDataBytesBudget(this.#model)), snapcompact.providerFrameBudget(this.#model), ); } /** * 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: EngineCompactionSettings, signal: AbortSignal, ): Promise { if (signal.aborted) return undefined; // Re-rendering frames needs a vision-capable model, same gate as the // snapcompact method. const model = this.#model; if (!model || !sendsImageInputOnWire(model)) 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 += this.#tokenizer.countMessage(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; let result: snapcompact.CompactionResult; try { result = await this.#rerenderSnapcompactArchive(staleEntry, archiveText, model, 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, { details: result.details, preserveData: result.preserveData, method: "snapcompact", tokensAfter: this.#projectCompactedContextTokens({ summary: result.summary, shortSummary: result.shortSummary, tokensBefore: result.tokensBefore, firstKeptEntryId: result.firstKeptEntryId, preserveData: result.preserveData, }), }, ); this.#host.persistEvalStateContext(); this.#failedNativeSpeculation = undefined; const sessionContext = this.#host.buildDisplaySessionContext(); this.#host.replaceMessagesFromSessionContext(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("compaction-rescue", { compacted: true }); 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; } /** * Re-render `entry`'s snapcompact archive from its kept source text for * `model`, at most `maxFrames` frames: the reader's frame shape and its * reasoning policy ({@link snapcompact.readerIncludesThinking}). Local: no model call. */ async #rerenderSnapcompactArchive( entry: CompactionEntry, archiveText: string, model: Model, maxFrames: number, ): Promise { const details = entry.details as snapcompact.CompactionDetails | undefined; const fileOps = snapcompact.createFileOps(); for (const file of details?.readFiles ?? []) fileOps.read.add(file); for (const file of details?.modifiedFiles ?? []) fileOps.edited.add(file); const shapeSetting = cfgSnapcompactShape.get(this.#host.settings); const shape = snapcompact.resolveShapeForText(archiveText, model, shapeSetting); return await snapcompact.compact( { firstKeptEntryId: entry.firstKeptEntryId, messagesToSummarize: [], turnPrefixMessages: [], tokensBefore: entry.tokensBefore, previousSummary: entry.summary, previousPreserveData: entry.preserveData, fileOps, }, { convertToLlm, model, ...(shapeSetting === "auto" ? {} : { shape }), maxFrames, includeThinking: snapcompact.readerIncludesThinking(model), }, ); } /** * Show the active model the snapcompact archive it may read, in the agent * context and in `activeMessages` (a running loop's context). * An archive is rendered for the model that compacted, so it may not suit * the model reading it now: * - `includeThinking` is fixed when an archive is rendered, so one built under * another model still carries `¶think:` sections and the reasoning legend * once the session moves to Claude; a model whose classifier refuses quoted * reasoning (the `classifier-cot-extraction` catalog axis) gets the * archive's no-reasoning view instead. * - A model that takes no image input would read every frame as an * image-omitted placeholder; it gets the archive's kept source as text in * place of the frames ({@link snapcompact.viewWithoutFrames}). * Any other model gets the original. * * Runs on every model change and before every model call; side requests * read through {@link messagesForModel}. */ async renderArchiveForModel(activeMessages?: AgentMessage[]): Promise { const model = this.#model; if (!model) return; const reader = archiveReader(model); // The views a scan swapped in were fitted to this model's window (#viewWithoutFrames). const scanKey = `${reader.key}|${model.provider}/${model.id}|${this.#archiveTextBudget(model)}`; const lists = activeMessages ? [this.#host.agent.state.messages, activeMessages] : [this.#host.agent.state.messages]; const scanned = lists.map(list => list.length); const replacements = await this.#summaryReplacements( model, ...lists.map(list => list.slice(this.#summaryScanStart(list, scanKey))), ); if (replacements.size > 0) { // Re-read after the awaits: the agent may have appended messages meanwhile. const agentMessages = this.#host.agent.state.messages; if (agentMessages.some(message => replacements.has(message))) { this.#host.agent.replaceMessages(agentMessages.map(message => replacements.get(message) ?? message)); } if (activeMessages) { let changed = false; for (let index = 0; index < activeMessages.length; index++) { const replacement = replacements.get(activeMessages[index]!); if (!replacement) continue; activeMessages[index] = replacement; changed = true; } if (changed) invalidateConvertToLlmArrayCache(activeMessages); } } lists.forEach((list, index) => { this.#summaryScans.set(list, { key: scanKey, length: scanned[index]!, first: list[0] }); }); } /** Where a scan of `list` for summaries to swap starts: past the prefix already * scanned for the same reader and budget, unless the list was cleared or refilled. */ #summaryScanStart(list: readonly AgentMessage[], scanKey: string): number { const scan = this.#summaryScans.get(list); return scan?.key === scanKey && scan.first === list[0] && scan.length <= list.length ? scan.length : 0; } /** * `messages` as `model` reads them: a copy with each snapcompact summary * swapped for the archive view `model` may read (see * {@link renderArchiveForModel}), or `messages` itself when none changes. */ async messagesForModel(messages: AgentMessage[], model: Model): Promise { const replacements = await this.#summaryReplacements(model, messages); return replacements.size === 0 ? messages : messages.map(message => replacements.get(message) ?? message); } async #summaryReplacements( model: Model, ...lists: (readonly AgentMessage[] | undefined)[] ): Promise> { const replacements = new Map(); const reader = archiveReader(model); for (const messages of lists) { for (const message of messages ?? []) { if (message.role !== "compactionSummary" || replacements.has(message)) continue; const original = this.#originalSummaries.get(message) ?? message; const wanted = (reader.withoutFrames && mayHoldArchive(original)) || (reader.excludeThinking && this.#mayReplayThinking(original)) ? await this.#readerSummary(original, model, reader) : original; if (wanted !== message) replacements.set(message, wanted); } } return replacements; } /** * Whether `message` may replay reasoning: its summary declares `¶think:` scopes, or the archive behind it * keeps them ({@link snapcompact.archiveIncludesThinking}). Decided once per message; a summary no archive * backs is looked up again only once the branch changes. */ #mayReplayThinking(message: CompactionSummaryMessage): boolean { if (snapcompact.summaryIncludesThinking(message.summary)) return true; if (!mayHoldArchive(message)) return false; const known = this.#replaysThinking.get(message); if (typeof known === "boolean") return known; const branch = this.#host.sessionManager.getBranchView(); if (known === branch) return false; const entry = this.#archiveEntry(message, branch); const archive = entry && snapcompact.getPreservedArchive(entry.preserveData); if (!archive) { this.#replaysThinking.set(message, branch); return false; } const replays = snapcompact.archiveIncludesThinking(message.summary, archive); this.#replaysThinking.set(message, replays); return replays; } /** * `messages`, rebuilt from `entries`, with each snapcompact summary in the form * `model` (the active model by default) reads when it takes no image input, * the way {@link renderArchiveForModel} will send it, so a local count * measures that request; for a reader with image input, a text copy goes back * to its original frames. Synchronous: the no-reasoning frames of an image * reader are left to {@link messagesForModel}. */ #messagesAsRead( messages: AgentMessage[], entries: readonly SessionEntry[], model: Model | undefined = this.#model, ): AgentMessage[] { if (!model) return messages; const reader = archiveReader(model); let read: AgentMessage[] | undefined; messages.forEach((message, index) => { if (message.role !== "compactionSummary") return; const original = this.#originalSummaries.get(message) ?? message; let wanted: AgentMessage = message; if (reader.withoutFrames && mayHoldArchive(original)) { const entry = this.#archiveEntry(original, entries); const view = entry && this.#viewWithoutFrames(entry, reader, model); wanted = view ? this.#readerMessage(original, view, reader) : original; } else if (!reader.withoutFrames && this.#textCopies.has(message)) { wanted = original; } if (wanted === message) return; read ??= messages.slice(); read[index] = wanted; }); return read ?? messages; } /** The compaction entry among `entries` whose snapcompact archive `message` was built from. */ #archiveEntry(message: CompactionSummaryMessage, entries: readonly SessionEntry[]): CompactionEntry | undefined { const cached = this.#summaryEntries.get(message); if (cached) return cached; const entry = entries.findLast( (entry): entry is CompactionEntry => entry.type === "compaction" && entry.summary === message.summary && new Date(entry.timestamp).getTime() === message.timestamp && snapcompact.getPreservedArchive(entry.preserveData) !== undefined, ); if (entry) this.#summaryEntries.set(message, entry); return entry; } /** `message` rebuilt from the archive view `reader` reads, or `message` itself when that is the original. */ async #readerSummary( message: CompactionSummaryMessage, model: Model, reader: ArchiveReader, ): Promise { const entry = this.#archiveEntry(message, this.#host.sessionManager.getBranchView()); let view: snapcompact.ArchiveView | undefined; if (entry) { view = reader.withoutFrames ? this.#viewWithoutFrames(entry, reader, model) : await this.#viewWithoutThinking(entry, model); } else if (reader.excludeThinking && snapcompact.summaryIncludesThinking(message.summary)) { // No archive behind it (e.g. archive text a summarizer folded into a plain // summary): scrub the summary text itself and keep no blocks. view = { summary: snapcompact.textWithoutThinking(message.summary), archive: { frames: [], totalChars: 0, truncatedChars: 0 }, }; } return view ? this.#readerMessage(message, view, reader) : message; } /** `message` rebuilt from `view`, one copy per original, reader and view. */ #readerMessage( message: CompactionSummaryMessage, view: snapcompact.ArchiveView, reader: ArchiveReader, ): CompactionSummaryMessage { let cache = this.#readerSummaries.get(message); if (!cache) { cache = new Map(); this.#readerSummaries.set(message, cache); } const cached = cache.get(reader.key); if (cached?.view === view) return cached.message; const blocks = this.#host.sessionManager.snapcompactHistoryBlocks(view.archive); const images = blocks.filter(block => block.type === "image"); const readerMessage: CompactionSummaryMessage = { ...message, summary: view.summary, blocks: blocks.length > 0 ? blocks : undefined, images: images.length > 0 ? images : undefined, }; cache.set(reader.key, { view, message: readerMessage }); this.#originalSummaries.set(readerMessage, message); if (reader.withoutFrames) this.#textCopies.add(readerMessage); return readerMessage; } /** * Tokens of archive text a reader without image input gets in `model`'s * window: the share snapcompact sizes an imaged archive to * ({@link SNAPCOMPACT_POST_COMPACTION_TARGET} of its compaction threshold), * so an archive built for a larger window still leaves the reader room to * work and to compact. `undefined` when the window is unknown. */ #archiveTextBudget(model: Model): number | undefined { const contextWindow = model.contextWindow ?? 0; if (contextWindow <= 0) return undefined; const settings = resolveModelCompactionSettings(this.#host.settings, model); return Math.floor(SNAPCOMPACT_POST_COMPACTION_TARGET * resolveThresholdTokens(contextWindow, settings)); } /** * `entry`'s archive as `model`, a reader without image input, reads it * ({@link snapcompact.viewWithoutFrames}), derived from its kept source once * per entry, reader and budget; `undefined` when that is the original. Text * past {@link #archiveTextBudget} loses its middle. */ #viewWithoutFrames( entry: CompactionEntry, reader: ArchiveReader, model: Model, ): snapcompact.ArchiveView | undefined { const targetTokens = this.#archiveTextBudget(model); const key = `${reader.key}|${model.provider}/${model.id}|${targetTokens}`; let views = this.#withoutFramesViews.get(entry); if (!views) { views = new Map(); this.#withoutFramesViews.set(entry, views); } let view = views.get(key); if (view === undefined) { const archive = snapcompact.getPreservedArchive(entry.preserveData)!; const options = { includeThinking: !reader.excludeThinking }; let unfolded = snapcompact.viewWithoutFrames(entry.summary, archive, options); // The original too: a frameless archive was sized for the window that compacted. if (targetTokens !== undefined) { const tokenizer = this.#tokenizer.countsAlike(model) ? this.#tokenizer : new Tokenizer(model); const keptText = (view: snapcompact.ArchiveView) => `${view.archive.textHead ?? ""}${view.archive.textTail ?? ""}`; let chars = keptText(unfolded).length; let tokens = tokenizer.countTokens(keptText(unfolded)); // Cut at the measured rate, then measure the kept ends again: they may tokenize more // densely than the dropped middle. Each pass cuts at least 2%. while (tokens > targetTokens && chars > 0) { chars = Math.min(Math.floor((chars * targetTokens) / tokens), Math.floor(chars * 0.98)); unfolded = snapcompact.viewWithoutFrames(entry.summary, archive, { ...options, maxChars: chars }); tokens = tokenizer.countTokens(keptText(unfolded)); } } view = unfolded.archive === archive ? null : unfolded; views.set(key, view); } return view ?? undefined; } #viewWithoutThinking(entry: CompactionEntry, model: Model): Promise { let view = this.#withoutThinkingViews.get(entry); if (!view) { view = this.#renderViewWithoutThinking(entry, model); this.#withoutThinkingViews.set(entry, view); } return view; } /** * The no-reasoning view of `entry`'s archive. One the entry lacks is * re-rendered locally from the archive's source at its frame count and * stored next to the original (`Archive.withoutThinking`), which stays * intact for readers that keep reasoning and for other branches. When the * render fails, the text-only form without frames is read instead. */ async #renderViewWithoutThinking(entry: CompactionEntry, model: Model): Promise { const archive = snapcompact.getPreservedArchive(entry.preserveData)!; const details = entry.details as Partial | undefined; // The stored variant, the original when it carries no reasoning, else the // text-only form without frames. const readableView = () => snapcompact.viewWithoutThinking( entry.summary, archive, Array.isArray(details?.readFiles) && Array.isArray(details?.modifiedFiles) ? { readFiles: details.readFiles, modifiedFiles: details.modifiedFiles } : undefined, ); const archiveText = snapcompact.archiveSourceText(archive); if (archive.withoutThinking || !snapcompact.archiveIncludesThinking(entry.summary, archive) || !archiveText) { return readableView(); } let view: snapcompact.ArchiveView; try { const result = await this.#rerenderSnapcompactArchive(entry, archiveText, model, archive.frames.length); const rendered = snapcompact.getPreservedArchive(result.preserveData); if (!rendered) throw new Error("The re-render produced no archive"); // A summary an extension wrote keeps its own words, without reasoning. view = { summary: snapcompact.viewSummary(entry.summary, result.summary, true), archive: rendered }; } catch (error) { logger.warn("Snapcompact archive render without reasoning failed; reading its text without frames", { error: error instanceof Error ? error.message : String(error), }); return readableView(); } const preserveData = entry.preserveData; const slot = preserveData?.[snapcompact.PRESERVE_KEY]; entry.preserveData = { ...preserveData, [snapcompact.PRESERVE_KEY]: { ...(isRecord(slot) ? slot : {}), withoutThinking: view }, }; try { await this.#host.sessionManager.rewriteEntries(); } catch (error) { entry.preserveData = preserveData; logger.warn("Persisting the snapcompact archive without reasoning failed", { error: error instanceof Error ? error.message : String(error), }); } return view; } /** * Internal: Run auto-compaction with events. * * Every method, handoff included, runs to completion before this resolves, * so the result reports the continuation that was actually scheduled — the * `agent_end` settle derives `willContinue` from it. * * @returns whether auto-compaction scheduled a follow-up turn. */ async runAutoCompaction( reason: "overflow" | "threshold" | "idle" | "incomplete", willRetry: boolean, options: { autoContinue?: boolean; triggerContextTokens?: number; /** Tokens from pending messages included in triggerContextTokens but absent from the prepared history. */ pendingContextTokens?: number; /** Stored context before pending messages, measured before preparation rewrites its representation. */ preparedContextTokens?: number; suppressContinuation?: boolean; phase?: CodexCompactionContext["phase"]; terminalTextAnswer?: boolean; /** Mid-turn: splice history then return; do not await UI/extension fan-out. */ detachPostCommit?: boolean; /** Index to resume from after an earlier preferred method failed. */ methodIndex?: number; /** A preceding shake already rewrote history before this fallback attempt. */ fallbackFromShake?: boolean; /** * This call services an explicit model-requested rollover * (`new_context`): it bypasses the Auto-Compact toggle, and never * falls through to ordinary summary compaction when the experimental * capability is no longer present at dispatch time. */ explicitNewContextRequest?: boolean; /** * Skip `snapcompact` regardless of model image support — a byte/payload- * limit 413 recovery must not retry with an even larger, media-heavy * request (#11482). */ excludeMediaMethods?: boolean; } = {}, ): Promise { const compactionSettings = this.#compactionSettings; // An explicit model-requested rollover bypasses the Auto-Compact toggle; // automatic threshold rollover stays gated exactly as before. const explicitNewContextRequest = options.explicitNewContextRequest === true; if (reason !== "idle" && !compactionSettings.enabled && !explicitNewContextRequest) { return COMPACTION_CHECK_NONE; } // Every threshold caller and strategy goes through here, so the fixed-context gate lives here once. if (reason === "threshold" && !explicitNewContextRequest) { const deadEnd = this.#thresholdFixedContextDeadEnd(); if (deadEnd) return deadEnd; } if (this.#usesExperimentalContextManagement()) { return await this.#runExperimentalContextRollover(reason, willRetry, options); } // The explicit request is valid only on the notes-backed rollover path. // If the experimental capability disappeared between request recognition // and this dispatch (tool surface or settings changed while tool results // were being persisted), drop the stale request rather than fall through // to ordinary summary compaction: no summary-provider request was agreed. if (explicitNewContextRequest) return COMPACTION_CHECK_NONE; const methods = resolveCompactionMethodOrder(compactionSettings.methodOrder); if (methods.length === 0) 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 startIndex = options.methodIndex ?? 0; let methodIndex = -1; let method: CompactionMethod | undefined; for (let index = startIndex; index < methods.length; index++) { const candidate = methods[index]; if ( !isCompactionMethodUsable( candidate, reason, this.#model, compactionSettings, options.excludeMediaMethods === true, ) ) continue; // Re-sending a native request that just failed for good only delays the // fallback. Skip it while a later method can still run. const liveModel = this.#model; if ( candidate === "remote" && liveModel && this.#nativeSpeculationFailed(liveModel) && methods .slice(index + 1) .some(next => isCompactionMethodUsable( next, reason, liveModel, compactionSettings, options.excludeMediaMethods === true, ), ) ) { logger.debug("Skipping native compaction after a failed speculative attempt", { model: `${liveModel.provider}/${liveModel.id}`, }); continue; } method = candidate; methodIndex = index; break; } if (!method) { return startIndex > 0 && reason === "threshold" ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE; } // A speculative pass may have already produced this compaction's summary // in the background. Claiming consumes the slot either way: an in-flight // run is aborted (this real pass supersedes it) and an armed result is // returned only when still valid for the current branch/model/settings. // Snapcompact is local and instant, so an armed LLM summary (possible // only when settings/model changed since arming) never overrides it. const claimedSpec = this.#claimArmedSpeculation(options.triggerContextTokens, options.pendingContextTokens); const armedSpec = method === "snapcompact" ? undefined : claimedSpec; const effectiveSettings = resolveMethodSettings(compactionSettings, method); const fallbackFromShake = options.fallbackFromShake === true; // Shake runs inline (cheap, no remote LLM). If it cannot recover enough // context, resume from the next configured method instead of hardcoding a // context-full summary. if (method === "shake" && !armedSpec) { const outcome = await this.#runAutoShake( reason, willRetry, generation, shouldAutoContinue, terminalTextAnswer, options.triggerContextTokens, suppressContinuation, options.detachPostCommit === true, ); if (outcome !== "fallback") return outcome; return await this.runAutoCompaction(reason, willRetry, { ...options, methodIndex: methodIndex + 1, fallbackFromShake: true, }); } const action: "context-full" | "handoff" | "snapcompact" | "remote" = armedSpec?.action ?? (method === "remote" ? "remote" : method === "snapcompact" ? "snapcompact" : method === "handoff" ? "handoff" : "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; let compactionCommitted = false; 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). const startEvent = { type: "auto_compaction_start" as const, reason, action }; await this.#emitLifecycleEvent(startEvent, false); if (armedSpec) { // A background speculation already produced this compaction's // summary; splice it in instead of paying for a blocking // summarization. tokensBefore reflects the live trigger size when // known — the armed value measured the smaller prefix at compute // time. logger.debug("Applying armed speculative compaction", { method: armedSpec.method, action, reason, }); const liveTriggerBefore = options.triggerContextTokens !== undefined ? Math.max(0, options.triggerContextTokens - (options.pendingContextTokens ?? 0)) : undefined; const branchBefore = this.#projectCurrentBranchContextTokens(); return await this.#commitAutoCompactionResult({ summary: armedSpec.result.summary, shortSummary: armedSpec.result.shortSummary, firstKeptEntryId: armedSpec.result.firstKeptEntryId, tokensBefore: Math.max(armedSpec.result.tokensBefore, liveTriggerBefore ?? 0, branchBefore), details: armedSpec.result.details, preserveData: armedSpec.result.preserveData, fromExtension: false, codexCompaction: armedSpec.codexCompaction, method: armedSpec.method, providerReplayThroughEntryId: armedSpec.result.providerReplayThroughEntryId ?? (armedSpec.result.preserveData?.openaiRemoteCompaction || (armedSpec.result.preserveData?.anthropicCompaction && this.#host.sessionManager.getBranch().at(-1)?.id !== armedSpec.snapshotLeafId) ? armedSpec.snapshotLeafId : undefined), action, reason, willRetry, generation, shouldAutoContinue, terminalTextAnswer, suppressContinuation, fallbackFromShake, detachPostCommit: options.detachPostCommit === true, autoCompactionSignal, onCommitted: () => { compactionCommitted = true; }, }); } if (!this.#model) { await this.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, skipped: true, }, options.detachPostCommit === true, ); return COMPACTION_CHECK_NONE; } const availableModels = this.#host.modelRegistry.getAvailable(); if (availableModels.length === 0) { await this.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, skipped: true, }, options.detachPostCommit === true, ); return COMPACTION_CHECK_NONE; } const pathEntries = this.#host.sessionManager.getBranch(); let pathEntriesForCompaction = pathEntries; let preparation = prepareCompaction(pathEntriesForCompaction, effectiveSettings, this.#model, this.#tokenizer); 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 // method (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, effectiveSettings, 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, effectiveSettings, this.#model, this.#tokenizer, ); return preparation !== undefined; }, }); } } if (!preparation) { const noProgressDeadEnd = reason !== "idle" && !frameRescueCreatedHeadroom; const implicatedFrames = frameRescueResult && !frameRescueCreatedHeadroom ? (snapcompact.getPreservedArchive(frameRescueResult.preserveData)?.frames.length ?? 0) : 0; const deadEndWarning = noProgressDeadEnd ? this.#compactionDeadEndWarning( this.#deadEndRemedies("shrink it (e.g. clear large tool output)", implicatedFrames), ) : 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.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: frameRescueResult && { ...frameRescueResult, preserveData: snapcompact.stripPreservedArchive(frameRescueResult.preserveData), }, aborted: false, willRetry: false, skipped: frameRescueResult === undefined, }, options.detachPostCommit === true, ); let continuationScheduled = false; if (frameRescueCreatedHeadroom) { continuationScheduled = this.#host.scheduleCompactionContinuation({ generation, autoContinue: shouldAutoContinue, terminalTextAnswer, suppressContinuation, }); } else if (!suppressContinuation && this.#host.agent.hasQueuedMessages()) { this.#host.scheduleAgentContinue({ source: "frame-rescue-queued-message", 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; let providerReplayThroughEntryId: string | 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.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }, options.detachPostCommit === true, ); return COMPACTION_CHECK_NONE; } if (hookResult?.compaction) { hookCompaction = hookResult.compaction; fromExtension = true; } } const compactionPrep = await this.#prepareCompactionFromHooks( preparation, hookCompaction, autoCompactionSignal, ); // Handoff runs as a summary source: generate the document off the live // context (cache-friendly side request), then commit it like any other // compaction summary. A failed generation advances to the next // configured preference. let handoffDocument: HandoffResult | undefined; if (action === "handoff" && compactionPrep.kind !== "fromHook") { handoffDocument = await this.#host.generateHandoffDocument(AUTO_HANDOFF_THRESHOLD_FOCUS, { autoTriggered: true, signal: autoCompactionSignal, }); if (autoCompactionSignal.aborted) { await this.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }, options.detachPostCommit === true, ); return COMPACTION_CHECK_NONE; } if (!handoffDocument) { logger.warn("Auto-handoff returned no document; trying next preferred compaction method", { reason, }); await this.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, errorMessage: "Auto-handoff returned no document; trying the next preferred compaction method.", }, options.detachPostCommit === true, ); return await this.runAutoCompaction(reason, willRetry, { ...options, methodIndex: methodIndex + 1, }); } } 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 the reading model's // frame price; #computeSnapcompactMaxFrames sizes the frame cap from the // room under the trigger so the compaction leaves space for new turns // instead of re-firing a few turns later. Any local blocker (unsupported // snapcompact glyphs, kept-history too large, post-render overflow, no room // under the trigger on a threshold pass) advances automatic // maintenance to the next // configured preference instead of wedging the session (#3659). Manual // `/compact snapcompact` remains local-only because its one-method override // leaves no fallback. let snapcompactResult: snapcompact.CompactionResult | undefined; let snapcompactBlocker: string | undefined; if (action === "snapcompact" && compactionPrep.kind !== "fromHook") { const snapcompactIncludeThinking = snapcompact.readerIncludesThinking(this.#model); const text = snapcompact.serializeConversation( convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)), { includeThinking: snapcompactIncludeThinking }, ); const probeText = snapcompact.renderabilityProbeText( text, preparation.previousPreserveData, preparation.previousSummary, ); const shapeSetting = cfgSnapcompactShape.get(this.#host.settings); 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}%); trying the next preferred compaction method.`; } else { const pendingTokens = options.pendingContextTokens ?? 0; const maxFrames = this.#computeSnapcompactMaxFrames(preparation, effectiveSettings, pendingTokens); 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; trying the next preferred compaction method."; } else { const rendered = await this.#renderSnapcompactArchive( preparation, { convertToLlm, model: this.#model, ...(shapeSetting === "auto" ? {} : { shape }), maxFrames, includeThinking: snapcompactIncludeThinking, }, effectiveSettings, pendingTokens, ); snapcompactResult = rendered.result; const framePayloadBytes = rendered.framePayloadBytes; const frameByteBudget = snapcompact.frameDataBytesBudget(this.#model); if (framePayloadBytes > frameByteBudget) { logger.warn("Snapcompact exceeded the per-request frame payload budget", { model: this.#model?.id, framePayloadBytes, budget: frameByteBudget, }); snapcompactBlocker = "snapcompact produced too much standing image payload; trying the next preferred compaction method."; snapcompactResult = undefined; } if (snapcompactResult) { const ctxWindow = this.#model?.contextWindow ?? 0; const budget = ctxWindow > 0 ? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings) : Number.POSITIVE_INFINITY; const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult); // Reduction check in the encrypted-reasoning-excluded domain so opaque // replay signatures cannot inflate the removable baseline (#10716); // `triggerContextTokens` is already an encrypted-excluded stored // estimate, and `#projectPreSnapcompactContextTokens` now matches it. const projectedForReduction = this.#projectSnapcompactContextTokens( preparation, snapcompactResult, { excludeEncryptedReasoning: true, }, ); // A stored trigger estimate counts the whole standing summary; take // back one a new archive cannot replace, as the local baseline does // (which also prices the prepared region when that is set). const unreplacedSummary = options.triggerContextTokens !== undefined && options.preparedContextTokens === undefined && !this.#replacesStandingSummary(preparation) ? this.#host.messages().find(message => message.role === "compactionSummary") : undefined; const reductionBaseline = options.triggerContextTokens !== undefined ? Math.max( 0, options.triggerContextTokens - (options.pendingContextTokens ?? 0) - (unreplacedSummary ? this.#tokenizer.countMessage(unreplacedSummary) : 0), ) : this.#projectPreSnapcompactContextTokens(preparation); const preparedReductionBaseline = options.preparedContextTokens === undefined ? reductionBaseline : Math.max(0, reductionBaseline - options.preparedContextTokens) + this.#projectPreSnapcompactContextTokens(preparation); if ( projectedForReduction >= preparedReductionBaseline || !this.#compactionReducesContext( preparation, pathEntriesForCompaction, this.#projectCurrentBranchContextTokens(pathEntriesForCompaction), snapcompactResult.firstKeptEntryId, projectedForReduction, preparedReductionBaseline, "snapcompact", ) ) { logger.warn("Snapcompact projection would not reduce context", { model: this.#model?.id, projected: projectedForReduction, reductionBaseline: preparedReductionBaseline, }); snapcompactBlocker = "snapcompact would not reduce context; trying the next preferred compaction method."; snapcompactResult = undefined; } else 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; trying the next preferred compaction method."; snapcompactResult = undefined; } else if ( !willRetry && reason !== "idle" && this.#snapcompactLeavesNoHeadroom(projectedForReduction + pendingTokens, effectiveSettings) ) { logger.warn("Snapcompact archive leaves no room under the compaction trigger", { model: this.#model?.id, projected: projectedForReduction, pendingTokens, }); // With no later summarizing method to try, a reducing archive that // fits the window still beats sending the whole history. Shake does // not count: it only elides what it finds and may drop nothing. const laterMethodUsable = methods .slice(methodIndex + 1) .some( next => next !== "shake" && isCompactionMethodUsable( next, reason, this.#model, compactionSettings, options.excludeMediaMethods === true, ), ); if (laterMethodUsable || projected + pendingTokens > budget) { snapcompactBlocker = "snapcompact could not leave room under the compaction trigger; trying the next preferred compaction method."; snapcompactResult = undefined; } else { this.#host.emitNotice( "warning", "snapcompact could not leave room under the compaction trigger; keeping its archive because no later summarizing compaction method is usable.", "compaction", ); } } } } } if (snapcompactBlocker) { this.#host.emitNotice("warning", snapcompactBlocker, "compaction"); await this.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, errorMessage: snapcompactBlocker, }, options.detachPostCommit === true, ); return await this.runAutoCompaction(reason, willRetry, { ...options, methodIndex: methodIndex + 1, }); } } if (compactionPrep.kind === "fromHook") { summary = compactionPrep.summary; shortSummary = compactionPrep.shortSummary; firstKeptEntryId = compactionPrep.firstKeptEntryId; tokensBefore = compactionPrep.tokensBefore; details = compactionPrep.details; preserveData = compactionPrep.preserveData; providerReplayThroughEntryId = compactionPrep.providerReplayThroughEntryId; } else if (handoffDocument) { const handoffSummary = handoffSummaryFromDocument(handoffDocument.document, preparation); summary = handoffSummary.summary; shortSummary = undefined; firstKeptEntryId = preparation.firstKeptEntryId; tokensBefore = preparation.tokensBefore; details = handoffSummary.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 liveModel = this.#model; const candidates = this.#getCompactionModelCandidates( availableModels, method === "remote" && !effectiveSettings.remoteEndpoint && liveModel ? candidate => canUseLiveProviderNativeCompaction(candidate, liveModel, effectiveSettings) : undefined, ); const retrySettings = cfgRetry.get(this.#host.settings); 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, loadExtraContext: compactionPrep.loadMemoryContext, remoteSystemPrompt: this.#host.agent.state.systemPrompt, metadata: this.#host.agent.metadataForProvider(candidate.provider), initiatorOverride: "agent", convertToLlm: messages => this.#host.convertToLlmForSideRequest(messages), buildProviderContext: (summarized, retained, signal, model) => this.#host.buildLiveProviderContext(summarized, retained, signal, model), isUserAuthored: isUserAuthoredMessage, 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(), // Same key split as the manual callsite in // #compactWithFallbackModel. promptCacheKey: this.#host.getPromptCacheKeyForModel(candidate), remotePromptCacheKey: this.#host.getPromptCacheKeyForModel( remoteCompactionPolicyModel(candidate), ), providerSessionState: this.#host.providerSessionState, preferWebsockets: this.#host.preferWebsockets(candidate), codexAccessPrograms: cfgProvidersOpenaiCodexDaybreak.get(this.#host.settings), codexCompaction, // This loop already retries the whole compaction attempt on // transient errors, so the summarization oneshots must not // retry too — the budgets would multiply and each outer // wait would stack on top of an inner backoff. oneshotRetry: false, }, ); 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); // An input the summarizer cannot fit is deterministic: the same // prompt fails identically every attempt, so the retry budget is // pure latency and the next candidate (a larger window) is the // only move that can succeed. Overflow therefore vetoes the // transient/usage-limit arms, which a provider blob can trip on // coincidence alone. const shouldRetry = retrySettings.enabled && attempt < retrySettings.maxRetries && !AIError.is(id, AIError.Flag.ContextOverflow) && (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; // With another candidate available, a long wait is not worth it. The // last candidate waits within the user's provider-wait cap // (`retry.maxDelayMs`, 0 = uncapped); past it, the outer method // cascade can still recover or report the failure (#15311). // `retry.waitForUsageReset` opts into sleeping through a provider- // stated usage-limit reset, as turn recovery does: only on the last // candidate, only with the provider's own retry hint, and never // longer than that hint. const waitForUsageReset = !hasMoreCandidates && retrySettings.waitForUsageReset === true && AIError.is(id, AIError.Flag.UsageLimit) && retryAfterMs !== undefined && delayMs <= retryAfterMs; const maxAcceptableDelayMs = hasMoreCandidates ? 30_000 : retrySettings.maxDelayMs > 0 && !waitForUsageReset ? retrySettings.maxDelayMs : Number.POSITIVE_INFINITY; if (delayMs > maxAcceptableDelayMs) { if (error instanceof NativeCompactionError) { nativeCompactionFailure ??= { error, provider: candidate.provider }; lastError = nativeCompactionFailure.error; break; } logger.warn("Auto-compaction retry delay too long, advancing fallback", { delayMs, retryAfterMs, error: message, model: `${candidate.provider}/${candidate.id}`, }); lastError = error; break; // Exit retry loop; try the next candidate or preferred method. } 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); providerReplayThroughEntryId = compactResult.providerReplayThroughEntryId; } const liveTriggerTokensBefore = options.triggerContextTokens !== undefined ? Math.max(0, options.triggerContextTokens - (options.pendingContextTokens ?? 0)) : undefined; const branchTokensBefore = this.#projectCurrentBranchContextTokens(pathEntriesForCompaction); const effectiveTokensBefore = Math.max(tokensBefore, liveTriggerTokensBefore ?? 0); const candidateTokensBefore = Math.max(effectiveTokensBefore, branchTokensBefore); const projectedAfter = this.#projectCompactedContextTokens({ summary, shortSummary, firstKeptEntryId, tokensBefore: candidateTokensBefore, details, preserveData, method: fromExtension ? undefined : method, providerReplayThroughEntryId, }); if ( compactionPrep.kind !== "fromHook" && !this.#compactionReducesContext( preparation, pathEntriesForCompaction, branchTokensBefore, firstKeptEntryId, projectedAfter, effectiveTokensBefore > 0 ? effectiveTokensBefore : branchTokensBefore, method, ) ) { logger.warn("Compaction projection would not reduce context", { model: this.#model?.id, method, projected: projectedAfter, branchTokensBefore, effectiveTokensBefore, }); const laterMethodUsable = methods .slice(methodIndex + 1) .some(next => isCompactionMethodUsable( next, reason, this.#model, compactionSettings, options.excludeMediaMethods === true, ), ); const noReductionMessage = laterMethodUsable ? `${action} compaction would not reduce context; trying the next preferred compaction method.` : `${action} compaction would not reduce context.`; this.#host.emitNotice("warning", noReductionMessage, "compaction"); await this.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, skipped: !laterMethodUsable, errorMessage: noReductionMessage, }, options.detachPostCommit === true, ); if (laterMethodUsable) { return await this.runAutoCompaction(reason, willRetry, { ...options, methodIndex: methodIndex + 1, }); } return reason === "threshold" ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE; } if (projectedAfter < candidateTokensBefore) { tokensBefore = candidateTokensBefore; } else if (liveTriggerTokensBefore !== undefined && liveTriggerTokensBefore > tokensBefore) { tokensBefore = liveTriggerTokensBefore; } return await this.#commitAutoCompactionResult({ summary, shortSummary, firstKeptEntryId, tokensBefore, details, preserveData, fromExtension, codexCompaction, method: fromExtension ? undefined : method, providerReplayThroughEntryId, action, reason, willRetry, generation, shouldAutoContinue, terminalTextAnswer, suppressContinuation, fallbackFromShake, detachPostCommit: options.detachPostCommit === true, autoCompactionSignal, onCommitted: () => { compactionCommitted = true; }, }); } catch (error) { if (autoCompactionSignal.aborted) { await this.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }, options.detachPostCommit === true, ); return COMPACTION_CHECK_NONE; } const errorMessage = error instanceof Error ? error.message : "compaction failed"; const contextErrorMessage = reason === "overflow" ? `Context overflow recovery failed: ${errorMessage}` : reason === "incomplete" ? `Incomplete response recovery failed: ${errorMessage}` : `Auto-compaction failed: ${errorMessage}`; if (!compactionCommitted && methodIndex + 1 < methods.length) { logger.warn("Automatic compaction method failed; trying next preference", { method, error: errorMessage, }); await this.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, errorMessage: `${contextErrorMessage}; trying the next preferred compaction method.`, }, options.detachPostCommit === true, ); return await this.runAutoCompaction(reason, willRetry, { ...options, methodIndex: methodIndex + 1, }); } await this.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, errorMessage: contextErrorMessage, }, options.detachPostCommit === true, ); } finally { if (this.#autoCompactionAbortController === autoCompactionAbortController) { this.#autoCompactionAbortController = undefined; } } return COMPACTION_CHECK_NONE; } /** * Shared auto-maintenance commit tail: append the compaction entry, splice * the rebuilt context into the live agent, run the post-commit progress * checks (retry fit / recovery band with the tiered dead-end rescue), emit * `auto_compaction_end`, and schedule the follow-up turn. Used by both the * blocking production path and the armed speculative-apply path. */ async #commitAutoCompactionResult(args: { summary: string; shortSummary: string | undefined; firstKeptEntryId: string; tokensBefore: number; details: unknown; preserveData: Record | undefined; fromExtension: boolean; codexCompaction: CodexCompactionContext | undefined; method: CompactionMethod | undefined; providerReplayThroughEntryId?: string; action: "context-full" | "handoff" | "snapcompact" | "remote"; reason: "overflow" | "threshold" | "idle" | "incomplete"; willRetry: boolean; generation: number; shouldAutoContinue: boolean; terminalTextAnswer: boolean; suppressContinuation: boolean; fallbackFromShake: boolean; detachPostCommit: boolean; autoCompactionSignal: AbortSignal; onCommitted: () => void; }): Promise { const { action, reason, willRetry, detachPostCommit, autoCompactionSignal } = args; if (autoCompactionSignal.aborted) { await this.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }, detachPostCommit, ); return COMPACTION_CHECK_NONE; } args.onCommitted(); const savedCompactionEntry = await this.#commitCompactionEntry({ summary: args.summary, shortSummary: args.shortSummary, firstKeptEntryId: args.firstKeptEntryId, tokensBefore: args.tokensBefore, details: args.details, fromExtension: args.fromExtension, preserveData: args.preserveData, codexCompaction: args.codexCompaction, method: args.method, providerReplayThroughEntryId: args.providerReplayThroughEntryId, advisorResetReason: "auto-compaction", detachExtensionEmit: detachPostCommit, }); const result: CompactionResult = { summary: args.summary, shortSummary: args.shortSummary, firstKeptEntryId: args.firstKeptEntryId, tokensBefore: args.tokensBefore, details: args.details, preserveData: snapcompact.stripPreservedArchive(args.preserveData), ...(args.providerReplayThroughEntryId ? { providerReplayThroughEntryId: args.providerReplayThroughEntryId } : {}), }; // 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: args.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: args.fallbackFromShake, hasProgress: () => this.#compactionCreatedHeadroom(), }); } if (!hasHeadroom) { noProgressDeadEnd = true; } } if ( savedCompactionEntry && getLatestCompactionEntry(this.#host.sessionManager.getBranch())?.id === savedCompactionEntry.id ) { const postRescueTokensAfter = isRecord(args.details) && args.details.kind === "experimental-context-rollover" ? this.#projectExperimentalContextRolloverTokens(args) : this.#projectCompactedContextTokens(args); if (savedCompactionEntry.tokensAfter !== postRescueTokensAfter) { savedCompactionEntry.tokensAfter = postRescueTokensAfter; await this.#host.sessionManager.rewriteEntries(); } } const deadEndWarning = noProgressDeadEnd ? this.#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.#emitLifecycleEvent( { type: "auto_compaction_end", action, result, aborted: false, willRetry }, detachPostCommit, ); if (retryFits) { this.#host.scheduleAgentContinue({ source: "compaction-retry", delayMs: 100, generation: args.generation, }); continuationScheduled = true; } else { continuationScheduled = this.#host.scheduleCompactionContinuation({ generation: args.generation, autoContinue: hasHeadroom && args.shouldAutoContinue, terminalTextAnswer: args.terminalTextAnswer, suppressContinuation: args.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; } /** * Run a shake-method 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"` when the caller should advance to the next configured * method; returns a check result when shake handled the maintenance itself. */ async #runAutoShake( reason: "overflow" | "threshold" | "idle" | "incomplete", willRetry: boolean, generation: number, autoContinue: boolean, terminalTextAnswer: boolean, triggerContextTokens?: number, suppressContinuation = false, detachPostCommit = false, ): Promise { const action = "shake"; this.#autoCompactionAbortController?.abort(); const controller = new AbortController(); this.#autoCompactionAbortController = controller; const signal = controller.signal; try { await this.#emitLifecycleEvent({ type: "auto_compaction_start", reason, action }, false); const result = await this.#host.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal }); if (signal.aborted) { await this.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }, detachPostCommit, ); 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 advance to the next preferred method 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 advance to the next preference // even though the post-prune history is already inside the recovery band. const contextWindow = this.#model?.contextWindow ?? 0; const compactionSettings = this.#compactionSettings; 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; trying the next preferred compaction method.` : "Auto-shake found nothing eligible to drop; trying the next preferred compaction method."; await this.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, skipped: !reclaimed, errorMessage, }, detachPostCommit, ); return "fallback"; } await this.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry, skipped: !reclaimed, }, detachPostCommit, ); 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({ source: "shake-retry", 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.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }, detachPostCommit, ); return COMPACTION_CHECK_NONE; } const message = error instanceof Error ? error.message : "shake failed"; await this.#emitLifecycleEvent( { type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, errorMessage: message, skipped: false, }, detachPostCommit, ); // 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. When `persist` is false (the default) the change is * applied as a session-scoped runtime override — like `setThinkingLevel` — so * transient callers (e.g. the `set_auto_compaction` RPC command) configure * only their own session instead of mutating the machine-global `config.yml`. * The settings panel passes `persist: true` to save the preference durably. */ setAutoCompactionEnabled(enabled: boolean, persist = false): void { if (persist) { cfgCompactionEnabled.set(this.#host.settings, enabled); cfgCompactionEnabled.clearOverride(this.#host.settings); } else { cfgCompactionEnabled.override(this.#host.settings, enabled); } if (enabled && resolveCompactionMethodOrder(cfgCompactionMethodOrder.get(this.#host.settings)).length === 0) { if (persist) { cfgCompactionMethodOrder.set(this.#host.settings, [...DEFAULT_COMPACTION_METHOD_ORDER]); } else { cfgCompactionMethodOrder.override(this.#host.settings, [...DEFAULT_COMPACTION_METHOD_ORDER]); } } } /** Whether automatic maintenance has an enabled method to run. */ get autoCompactionEnabled(): boolean { return ( cfgCompactionEnabled.get(this.#host.settings) && resolveCompactionMethodOrder(cfgCompactionMethodOrder.get(this.#host.settings)).length > 0 ); } }