fix(compact): recover from prompt-too-long errors

Add an external-build recovery path that withholds prompt-too-long API errors, compacts the current context, and retries once before surfacing the original error.

Co-Authored-By: Claude GPT-5.5 <noreply@anthropic.com>
This commit is contained in:
你的姓名 2026-06-13 15:50:42 +08:00
parent 317bb28510
commit f42fd0905c
5 changed files with 258 additions and 37 deletions

View File

@ -11,10 +11,8 @@ import {
type AutoCompactTrackingState,
} from './services/compact/autoCompact.js'
import { buildPostCompactMessages } from './services/compact/compact.js'
import * as reactiveCompact from './services/compact/reactiveCompact.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const reactiveCompact = feature('REACTIVE_COMPACT')
? (require('./services/compact/reactiveCompact.js') as typeof import('./services/compact/reactiveCompact.js'))
: null
const contextCollapse = feature('CONTEXT_COLLAPSE')
? (require('./services/contextCollapse/index.js') as typeof import('./services/contextCollapse/index.js'))
: null

View File

@ -47,6 +47,7 @@ describe('context-window-overflow relay errors', () => {
'API Error: 400 {"error":{"type":"context_too_large","message":"Your input exceeds the context window of this model. Please adjust your input and try again."}}',
'Your input exceeds the context window of this model.',
'context_too_large',
'This model maximum context length exceeded. Please reduce your prompt.',
]
for (const message of overflowErrors) {
expect(isContextWindowExceededMessage(message)).toBe(true)

View File

@ -93,7 +93,9 @@ export function isContextWindowExceededMessage(message: string): boolean {
return (
raw.includes('context_too_large') ||
raw.includes('exceeds the context window') ||
raw.includes('exceed the context window')
raw.includes('exceed the context window') ||
raw.includes('maximum context length') ||
raw.includes('context length exceeded')
)
}

View File

@ -0,0 +1,160 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
const compactConversationMock = mock(async () => fakeCompactionResult())
mock.module('./compact.js', () => ({
compactConversation: compactConversationMock,
ERROR_MESSAGE_PROMPT_TOO_LONG:
'Conversation too long. Press esc twice to go up a few messages and try again.',
ERROR_MESSAGE_USER_ABORT: 'API Error: Request was aborted.',
}))
const reactiveCompact = await import('./reactiveCompact.js')
function fakeAssistantMessage(text: string, isApiErrorMessage = true) {
return {
type: 'assistant',
isApiErrorMessage,
message: {
content: [{ type: 'text', text }],
},
} as any
}
function fakeUserMessage(content: string) {
return {
type: 'user',
message: { content },
} as any
}
function fakeCompactBoundary() {
return {
type: 'system',
subtype: 'compact_boundary',
} as any
}
function fakeCompactionResult() {
return {
boundaryMarker: fakeCompactBoundary(),
summaryMessages: [fakeUserMessage('summary')],
attachments: [],
hookResults: [],
} as any
}
function fakeCacheSafeParams(messages = [fakeUserMessage('hello')]) {
return {
systemPrompt: [] as any,
userContext: {},
systemContext: {},
toolUseContext: {
abortController: new AbortController(),
} as any,
forkContextMessages: messages,
}
}
describe('reactiveCompact prompt-too-long recovery', () => {
beforeEach(() => {
compactConversationMock.mockClear()
compactConversationMock.mockImplementation(async () => fakeCompactionResult())
})
test('withholds canonical prompt-too-long API errors', () => {
expect(
reactiveCompact.isWithheldPromptTooLong(
fakeAssistantMessage('Prompt is too long'),
),
).toBe(true)
expect(
reactiveCompact.isWithheldPromptTooLong(
fakeAssistantMessage('Some other API error'),
),
).toBe(false)
expect(
reactiveCompact.isWithheldPromptTooLong(
fakeAssistantMessage('Prompt is too long', false),
),
).toBe(false)
expect(reactiveCompact.isWithheldPromptTooLong(fakeUserMessage('hi'))).toBe(
false,
)
})
test('does not withhold media errors in the prompt-too-long recovery shim', () => {
expect(
reactiveCompact.isWithheldMediaSizeError(
fakeAssistantMessage('image exceeds maximum'),
),
).toBe(false)
})
test('does not compact after a previous recovery attempt', async () => {
const result = await reactiveCompact.tryReactiveCompact({
hasAttempted: true,
aborted: false,
messages: [fakeUserMessage('hello')],
cacheSafeParams: fakeCacheSafeParams(),
})
expect(result).toBeNull()
expect(compactConversationMock).not.toHaveBeenCalled()
})
test('does not compact after abort', async () => {
const result = await reactiveCompact.tryReactiveCompact({
hasAttempted: false,
aborted: true,
messages: [fakeUserMessage('hello')],
cacheSafeParams: fakeCacheSafeParams(),
})
expect(result).toBeNull()
expect(compactConversationMock).not.toHaveBeenCalled()
})
test('compacts messages after the latest compact boundary and marks it automatic', async () => {
const beforeBoundary = fakeUserMessage('old')
const boundary = fakeCompactBoundary()
const afterBoundary = fakeUserMessage('new')
const cacheSafeParams = fakeCacheSafeParams([beforeBoundary, boundary, afterBoundary])
const result = await reactiveCompact.tryReactiveCompact({
hasAttempted: false,
aborted: false,
messages: [beforeBoundary, boundary, afterBoundary],
cacheSafeParams,
})
expect(result).toEqual(fakeCompactionResult())
expect(compactConversationMock).toHaveBeenCalledTimes(1)
expect(compactConversationMock.mock.calls[0]?.[0]).toEqual([
boundary,
afterBoundary,
])
expect(compactConversationMock.mock.calls[0]?.[2].forkContextMessages).toEqual([
boundary,
afterBoundary,
])
expect(compactConversationMock.mock.calls[0]?.[3]).toBe(true)
expect(compactConversationMock.mock.calls[0]?.[4]).toBeUndefined()
expect(compactConversationMock.mock.calls[0]?.[5]).toBe(true)
})
test('returns null when compaction fails', async () => {
compactConversationMock.mockImplementation(async () => {
throw new Error('compact failed')
})
const result = await reactiveCompact.tryReactiveCompact({
hasAttempted: false,
aborted: false,
messages: [fakeUserMessage('hello')],
cacheSafeParams: fakeCacheSafeParams(),
})
expect(result).toBeNull()
})
})

View File

@ -1,34 +1,94 @@
// @generated stub from scan-missing-imports
// 该文件自动生成,对应 ant-internal 的 feature() gated 模块。
// 所有外部 build 的代码路径在 DCE 后都不会真的执行这里的代码,这只是
// bun build resolver 的占位符。
const __target = function noop() {}
const __handler: ProxyHandler<any> = {
get(_t, prop) {
if (prop === '__esModule') return true
if (prop === 'default') return new Proxy(__target, __handler)
if (prop === Symbol.toPrimitive) return () => undefined
if (prop === Symbol.iterator) return function* () {}
if (prop === Symbol.asyncIterator) return async function* () {}
if (prop === 'then') return undefined
return new Proxy(__target, __handler)
},
apply() {
return new Proxy(__target, __handler)
},
construct() {
return new Proxy(__target, __handler)
},
import type { AssistantMessage, Message } from '../../types/message.js'
import type { CacheSafeParams } from '../../utils/forkedAgent.js'
import { hasExactErrorMessage } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
import { isPromptTooLongMessage } from '../api/errors.js'
import {
compactConversation,
ERROR_MESSAGE_PROMPT_TOO_LONG,
ERROR_MESSAGE_USER_ABORT,
type CompactionResult,
} from './compact.js'
export type ReactiveCompactOutcome =
| { ok: true; result: CompactionResult }
| {
ok: false
reason: 'too_few_groups' | 'aborted' | 'exhausted' | 'error' | 'media_unstrippable'
}
export function isWithheldPromptTooLong(
message: Message | undefined,
): message is AssistantMessage {
return message?.type === 'assistant' && isPromptTooLongMessage(message)
}
const stub: any = new Proxy(__target, __handler)
export default stub
export const __stubMissing = true
// 兼容常见的命名导出 —— 没列在这里的也会通过 default Proxy 兜底
export const createCachedMCState = stub
export const isCachedMicrocompactEnabled = stub
export const isModelSupportedForCacheEditing = stub
export const getCachedMCConfig = stub
export const markToolsSentToAPI = stub
export const resetCachedMCState = stub
export const checkProtectedNamespace = stub
export const getCoordinatorUserContext = stub
export function isWithheldMediaSizeError(_message: Message | undefined): boolean {
return false
}
export function isReactiveCompactEnabled(): boolean {
return true
}
export function isReactiveOnlyMode(): boolean {
return false
}
export async function tryReactiveCompact({
hasAttempted,
aborted,
messages,
cacheSafeParams,
}: {
hasAttempted: boolean
querySource?: unknown
aborted: boolean
messages: Message[]
cacheSafeParams: CacheSafeParams
}): Promise<CompactionResult | null> {
if (hasAttempted || aborted) return null
const messagesForCompact = getMessagesAfterCompactBoundary(messages)
if (messagesForCompact.length === 0) return null
try {
return await compactConversation(
messagesForCompact,
cacheSafeParams.toolUseContext,
{
...cacheSafeParams,
forkContextMessages: messagesForCompact,
},
true,
undefined,
true,
)
} catch (error) {
if (
!hasExactErrorMessage(error, ERROR_MESSAGE_PROMPT_TOO_LONG) &&
!hasExactErrorMessage(error, ERROR_MESSAGE_USER_ABORT)
) {
logError(error)
}
return null
}
}
export async function reactiveCompactOnPromptTooLong(
_messages: Message[],
_cacheSafeParams: CacheSafeParams,
_options?: { customInstructions?: string; trigger?: 'manual' | 'auto' },
): Promise<ReactiveCompactOutcome> {
return { ok: false, reason: 'error' }
}
export const createCachedMCState = undefined
export const isCachedMicrocompactEnabled = () => false
export const isModelSupportedForCacheEditing = () => false
export const getCachedMCConfig = () => undefined
export const markToolsSentToAPI = () => undefined
export const resetCachedMCState = () => undefined
export const checkProtectedNamespace = () => undefined
export const getCoordinatorUserContext = () => undefined