feat(#61): Implement fallback mechanism for auto-compact token limit recovery
- 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)
This commit is contained in:
@@ -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<unknown>
|
||||
revert: (opts: {
|
||||
path: { id: string }
|
||||
body: { messageID: string; partID?: string }
|
||||
query: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
tui: {
|
||||
submitPrompt: (opts: { query: { directory: string } }) => Promise<unknown>
|
||||
@@ -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<string, unknown>
|
||||
const info = msg.info as Record<string, unknown> | undefined
|
||||
return info?.role === "assistant"
|
||||
})
|
||||
|
||||
const lastUser = reversed.find((m) => {
|
||||
const msg = m as Record<string, unknown>
|
||||
const info = msg.info as Record<string, unknown> | undefined
|
||||
return info?.role === "user"
|
||||
})
|
||||
|
||||
if (!lastUser) return null
|
||||
const userInfo = (lastUser as { info?: Record<string, unknown> }).info
|
||||
const userMessageID = userInfo?.id as string | undefined
|
||||
if (!userMessageID) return null
|
||||
|
||||
let assistantMessageID: string | undefined
|
||||
if (lastAssistant) {
|
||||
const assistantInfo = (lastAssistant as { info?: Record<string, unknown> }).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<boolean> {
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ function createAutoCompactState(): AutoCompactState {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
|
||||
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"
|
||||
|
||||
@@ -12,15 +12,26 @@ export interface RetryState {
|
||||
lastAttemptTime: number
|
||||
}
|
||||
|
||||
export interface FallbackState {
|
||||
revertAttempt: number
|
||||
lastRevertedMessageID?: string
|
||||
}
|
||||
|
||||
export interface AutoCompactState {
|
||||
pendingCompact: Set<string>
|
||||
errorDataBySession: Map<string, ParsedTokenLimitError>
|
||||
retryStateBySession: Map<string, RetryState>
|
||||
fallbackStateBySession: Map<string, FallbackState>
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user