fix(anthropic-auto-compact): handle empty messages at arbitrary indices
- Add messageIndex field to ParsedTokenLimitError type for tracking message position - Extract message index from 'messages.N' format in error messages using regex - Update fixEmptyMessages to accept optional messageIndex parameter - Target specific empty message by index instead of fixing all empty messages - Apply replaceEmptyTextParts before injectTextPart for better coverage - Remove experimental flag requirement - non-empty content errors now auto-recover by default - Fixes issue where compaction could create empty messages at positions other than the last message 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
@@ -2,7 +2,12 @@ import type { AutoCompactState, FallbackState, RetryState, TruncateState } from
|
|||||||
import type { ExperimentalConfig } from "../../config"
|
import type { ExperimentalConfig } from "../../config"
|
||||||
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types"
|
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types"
|
||||||
import { findLargestToolResult, truncateToolResult, truncateUntilTargetTokens } from "./storage"
|
import { findLargestToolResult, truncateToolResult, truncateUntilTargetTokens } from "./storage"
|
||||||
import { findEmptyMessages, injectTextPart } from "../session-recovery/storage"
|
import {
|
||||||
|
findEmptyMessages,
|
||||||
|
findEmptyMessageByIndex,
|
||||||
|
injectTextPart,
|
||||||
|
replaceEmptyTextParts,
|
||||||
|
} from "../session-recovery/storage"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
type Client = {
|
type Client = {
|
||||||
@@ -168,30 +173,61 @@ function getOrCreateEmptyContentAttempt(
|
|||||||
async function fixEmptyMessages(
|
async function fixEmptyMessages(
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
autoCompactState: AutoCompactState,
|
autoCompactState: AutoCompactState,
|
||||||
client: Client
|
client: Client,
|
||||||
|
messageIndex?: number
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID)
|
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID)
|
||||||
autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1)
|
autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1)
|
||||||
|
|
||||||
const emptyMessageIds = findEmptyMessages(sessionID)
|
let fixed = false
|
||||||
if (emptyMessageIds.length === 0) {
|
const fixedMessageIds: string[] = []
|
||||||
await client.tui
|
|
||||||
.showToast({
|
if (messageIndex !== undefined) {
|
||||||
body: {
|
const targetMessageId = findEmptyMessageByIndex(sessionID, messageIndex)
|
||||||
title: "Empty Content Error",
|
if (targetMessageId) {
|
||||||
message: "No empty messages found in storage. Cannot auto-recover.",
|
const replaced = replaceEmptyTextParts(targetMessageId, "[user interrupted]")
|
||||||
variant: "error",
|
if (replaced) {
|
||||||
duration: 5000,
|
fixed = true
|
||||||
},
|
fixedMessageIds.push(targetMessageId)
|
||||||
})
|
} else {
|
||||||
.catch(() => {})
|
const injected = injectTextPart(sessionID, targetMessageId, "[user interrupted]")
|
||||||
return false
|
if (injected) {
|
||||||
|
fixed = true
|
||||||
|
fixedMessageIds.push(targetMessageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let fixed = false
|
if (!fixed) {
|
||||||
for (const messageID of emptyMessageIds) {
|
const emptyMessageIds = findEmptyMessages(sessionID)
|
||||||
const success = injectTextPart(sessionID, messageID, "[user interrupted]")
|
if (emptyMessageIds.length === 0) {
|
||||||
if (success) fixed = true
|
await client.tui
|
||||||
|
.showToast({
|
||||||
|
body: {
|
||||||
|
title: "Empty Content Error",
|
||||||
|
message: "No empty messages found in storage. Cannot auto-recover.",
|
||||||
|
variant: "error",
|
||||||
|
duration: 5000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const messageID of emptyMessageIds) {
|
||||||
|
const replaced = replaceEmptyTextParts(messageID, "[user interrupted]")
|
||||||
|
if (replaced) {
|
||||||
|
fixed = true
|
||||||
|
fixedMessageIds.push(messageID)
|
||||||
|
} else {
|
||||||
|
const injected = injectTextPart(sessionID, messageID, "[user interrupted]")
|
||||||
|
if (injected) {
|
||||||
|
fixed = true
|
||||||
|
fixedMessageIds.push(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fixed) {
|
if (fixed) {
|
||||||
@@ -199,7 +235,7 @@ async function fixEmptyMessages(
|
|||||||
.showToast({
|
.showToast({
|
||||||
body: {
|
body: {
|
||||||
title: "Session Recovery",
|
title: "Session Recovery",
|
||||||
message: `Fixed ${emptyMessageIds.length} empty messages. Retrying...`,
|
message: `Fixed ${fixedMessageIds.length} empty message(s). Retrying...`,
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
},
|
},
|
||||||
@@ -361,10 +397,15 @@ export async function executeCompact(
|
|||||||
|
|
||||||
const retryState = getOrCreateRetryState(autoCompactState, sessionID)
|
const retryState = getOrCreateRetryState(autoCompactState, sessionID)
|
||||||
|
|
||||||
if (experimental?.empty_message_recovery && errorData?.errorType?.includes("non-empty content")) {
|
if (errorData?.errorType?.includes("non-empty content")) {
|
||||||
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID)
|
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID)
|
||||||
if (attempt < 3) {
|
if (attempt < 3) {
|
||||||
const fixed = await fixEmptyMessages(sessionID, autoCompactState, client as Client)
|
const fixed = await fixEmptyMessages(
|
||||||
|
sessionID,
|
||||||
|
autoCompactState,
|
||||||
|
client as Client,
|
||||||
|
errorData.messageIndex
|
||||||
|
)
|
||||||
if (fixed) {
|
if (fixed) {
|
||||||
autoCompactState.compactionInProgress.delete(sessionID)
|
autoCompactState.compactionInProgress.delete(sessionID)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const TOKEN_LIMIT_KEYWORDS = [
|
|||||||
"non-empty content",
|
"non-empty content",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const MESSAGE_INDEX_PATTERN = /messages\.(\d+)/
|
||||||
|
|
||||||
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
|
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
|
||||||
for (const pattern of TOKEN_LIMIT_PATTERNS) {
|
for (const pattern of TOKEN_LIMIT_PATTERNS) {
|
||||||
const match = message.match(pattern)
|
const match = message.match(pattern)
|
||||||
@@ -40,6 +42,14 @@ function extractTokensFromMessage(message: string): { current: number; max: numb
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractMessageIndex(text: string): number | undefined {
|
||||||
|
const match = text.match(MESSAGE_INDEX_PATTERN)
|
||||||
|
if (match) {
|
||||||
|
return parseInt(match[1], 10)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
function isTokenLimitError(text: string): boolean {
|
function isTokenLimitError(text: string): boolean {
|
||||||
const lower = text.toLowerCase()
|
const lower = text.toLowerCase()
|
||||||
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
|
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
|
||||||
@@ -52,6 +62,7 @@ export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitErr
|
|||||||
currentTokens: 0,
|
currentTokens: 0,
|
||||||
maxTokens: 0,
|
maxTokens: 0,
|
||||||
errorType: "non-empty content",
|
errorType: "non-empty content",
|
||||||
|
messageIndex: extractMessageIndex(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isTokenLimitError(err)) {
|
if (isTokenLimitError(err)) {
|
||||||
@@ -155,6 +166,7 @@ export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitErr
|
|||||||
currentTokens: 0,
|
currentTokens: 0,
|
||||||
maxTokens: 0,
|
maxTokens: 0,
|
||||||
errorType: "non-empty content",
|
errorType: "non-empty content",
|
||||||
|
messageIndex: extractMessageIndex(combinedText),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface ParsedTokenLimitError {
|
|||||||
errorType: string
|
errorType: string
|
||||||
providerID?: string
|
providerID?: string
|
||||||
modelID?: string
|
modelID?: string
|
||||||
|
messageIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RetryState {
|
export interface RetryState {
|
||||||
|
|||||||
Reference in New Issue
Block a user