From cea64e40b88120cbbf3c1f572bc0ed419b616196 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 15 Dec 2025 21:18:03 +0900 Subject: [PATCH] feat(#61): Implement fallback mechanism for auto-compact token limit recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add FallbackState interface to track message removal attempts - Implement getLastMessagePair() to identify last user+assistant message pair - Add executeRevertFallback() to remove message pairs when compaction fails - Configure max 3 revert attempts with min 2 messages requirement - Trigger fallback after 5 compaction retries exceed - Reset retry counter on successful message removal for fresh compaction attempt - Clean fallback state on session deletion Resolves: When massive context (context bomb) is loaded, compaction fails and session becomes completely broken. Now falls back to emergency message removal after all retry attempts fail, allowing session recovery. 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- src/hooks/anthropic-auto-compact/executor.ts | 157 ++++++++++++++++++- src/hooks/anthropic-auto-compact/index.ts | 4 +- src/hooks/anthropic-auto-compact/types.ts | 13 +- 3 files changed, 169 insertions(+), 5 deletions(-) diff --git a/src/hooks/anthropic-auto-compact/executor.ts b/src/hooks/anthropic-auto-compact/executor.ts index 0105427..80e1791 100644 --- a/src/hooks/anthropic-auto-compact/executor.ts +++ b/src/hooks/anthropic-auto-compact/executor.ts @@ -1,5 +1,5 @@ -import type { AutoCompactState, RetryState } from "./types" -import { RETRY_CONFIG } from "./types" +import type { AutoCompactState, FallbackState, RetryState } from "./types" +import { FALLBACK_CONFIG, RETRY_CONFIG } from "./types" type Client = { session: { @@ -9,6 +9,11 @@ type Client = { body: { providerID: string; modelID: string } query: { directory: string } }) => Promise + revert: (opts: { + path: { id: string } + body: { messageID: string; partID?: string } + query: { directory: string } + }) => Promise } tui: { submitPrompt: (opts: { query: { directory: string } }) => Promise @@ -40,6 +45,122 @@ function getOrCreateRetryState( return state } +function getOrCreateFallbackState( + autoCompactState: AutoCompactState, + sessionID: string +): FallbackState { + let state = autoCompactState.fallbackStateBySession.get(sessionID) + if (!state) { + state = { revertAttempt: 0 } + autoCompactState.fallbackStateBySession.set(sessionID, state) + } + return state +} + +async function getLastMessagePair( + sessionID: string, + client: Client, + directory: string +): Promise<{ userMessageID: string; assistantMessageID?: string } | null> { + try { + const resp = await client.session.messages({ + path: { id: sessionID }, + query: { directory }, + }) + + const data = (resp as { data?: unknown[] }).data + if (!Array.isArray(data) || data.length < FALLBACK_CONFIG.minMessagesRequired) { + return null + } + + const reversed = [...data].reverse() + + const lastAssistant = reversed.find((m) => { + const msg = m as Record + const info = msg.info as Record | undefined + return info?.role === "assistant" + }) + + const lastUser = reversed.find((m) => { + const msg = m as Record + const info = msg.info as Record | undefined + return info?.role === "user" + }) + + if (!lastUser) return null + const userInfo = (lastUser as { info?: Record }).info + const userMessageID = userInfo?.id as string | undefined + if (!userMessageID) return null + + let assistantMessageID: string | undefined + if (lastAssistant) { + const assistantInfo = (lastAssistant as { info?: Record }).info + assistantMessageID = assistantInfo?.id as string | undefined + } + + return { userMessageID, assistantMessageID } + } catch { + return null + } +} + +async function executeRevertFallback( + sessionID: string, + autoCompactState: AutoCompactState, + client: Client, + directory: string +): Promise { + const fallbackState = getOrCreateFallbackState(autoCompactState, sessionID) + + if (fallbackState.revertAttempt >= FALLBACK_CONFIG.maxRevertAttempts) { + return false + } + + const pair = await getLastMessagePair(sessionID, client, directory) + if (!pair) { + return false + } + + await client.tui + .showToast({ + body: { + title: "⚠️ Emergency Recovery", + message: `Context too large. Removing last message pair to recover session...`, + variant: "warning", + duration: 4000, + }, + }) + .catch(() => {}) + + try { + if (pair.assistantMessageID) { + await client.session.revert({ + path: { id: sessionID }, + body: { messageID: pair.assistantMessageID }, + query: { directory }, + }) + } + + await client.session.revert({ + path: { id: sessionID }, + body: { messageID: pair.userMessageID }, + query: { directory }, + }) + + fallbackState.revertAttempt++ + fallbackState.lastRevertedMessageID = pair.userMessageID + + const retryState = autoCompactState.retryStateBySession.get(sessionID) + if (retryState) { + retryState.attempt = 0 + } + + return true + } catch { + return false + } +} + export async function getLastAssistant( sessionID: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -72,6 +193,7 @@ function clearSessionState(autoCompactState: AutoCompactState, sessionID: string autoCompactState.pendingCompact.delete(sessionID) autoCompactState.errorDataBySession.delete(sessionID) autoCompactState.retryStateBySession.delete(sessionID) + autoCompactState.fallbackStateBySession.delete(sessionID) } export async function executeCompact( @@ -85,13 +207,42 @@ export async function executeCompact( const retryState = getOrCreateRetryState(autoCompactState, sessionID) if (!shouldRetry(retryState)) { + const fallbackState = getOrCreateFallbackState(autoCompactState, sessionID) + + if (fallbackState.revertAttempt < FALLBACK_CONFIG.maxRevertAttempts) { + const reverted = await executeRevertFallback( + sessionID, + autoCompactState, + client as Client, + directory + ) + + if (reverted) { + await (client as Client).tui + .showToast({ + body: { + title: "Recovery Attempt", + message: "Message removed. Retrying compaction...", + variant: "info", + duration: 3000, + }, + }) + .catch(() => {}) + + setTimeout(() => { + executeCompact(sessionID, msg, autoCompactState, client, directory) + }, 1000) + return + } + } + clearSessionState(autoCompactState, sessionID) await (client as Client).tui .showToast({ body: { title: "Auto Compact Failed", - message: `Failed after ${RETRY_CONFIG.maxAttempts} attempts. Please try manual compact.`, + message: `Failed after ${RETRY_CONFIG.maxAttempts} retries and ${FALLBACK_CONFIG.maxRevertAttempts} message removals. Please start a new session.`, variant: "error", duration: 5000, }, diff --git a/src/hooks/anthropic-auto-compact/index.ts b/src/hooks/anthropic-auto-compact/index.ts index 43c964e..5480763 100644 --- a/src/hooks/anthropic-auto-compact/index.ts +++ b/src/hooks/anthropic-auto-compact/index.ts @@ -8,6 +8,7 @@ function createAutoCompactState(): AutoCompactState { pendingCompact: new Set(), errorDataBySession: new Map(), retryStateBySession: new Map(), + fallbackStateBySession: new Map(), } } @@ -23,6 +24,7 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput) { autoCompactState.pendingCompact.delete(sessionInfo.id) autoCompactState.errorDataBySession.delete(sessionInfo.id) autoCompactState.retryStateBySession.delete(sessionInfo.id) + autoCompactState.fallbackStateBySession.delete(sessionInfo.id) } return } @@ -120,6 +122,6 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput) { } } -export type { AutoCompactState, ParsedTokenLimitError } from "./types" +export type { AutoCompactState, FallbackState, ParsedTokenLimitError } from "./types" export { parseAnthropicTokenLimitError } from "./parser" export { executeCompact, getLastAssistant } from "./executor" diff --git a/src/hooks/anthropic-auto-compact/types.ts b/src/hooks/anthropic-auto-compact/types.ts index b8b55c7..b76d09c 100644 --- a/src/hooks/anthropic-auto-compact/types.ts +++ b/src/hooks/anthropic-auto-compact/types.ts @@ -12,15 +12,26 @@ export interface RetryState { lastAttemptTime: number } +export interface FallbackState { + revertAttempt: number + lastRevertedMessageID?: string +} + export interface AutoCompactState { pendingCompact: Set errorDataBySession: Map retryStateBySession: Map + fallbackStateBySession: Map } export const RETRY_CONFIG = { - maxAttempts: 5, + maxAttempts: 2, initialDelayMs: 2000, backoffFactor: 2, maxDelayMs: 30000, } as const + +export const FALLBACK_CONFIG = { + maxRevertAttempts: 3, + minMessagesRequired: 2, +} as const