mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
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:
parent
317bb28510
commit
f42fd0905c
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
160
src/services/compact/reactiveCompact.test.ts
Normal file
160
src/services/compact/reactiveCompact.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user