From 20bb48128b7a1b59800d963dc8d33dd305a0c3dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 25 Sep 2026 14:50:35 +0800 Subject: [PATCH] fix: recover oversized conversations using provider token limits --- .../__tests__/session-messages-http.test.ts | 32 +++++++++ src/services/api/errors.test.ts | 46 +++++++++++++ src/services/api/errors.ts | 16 +++-- src/services/compact/compact.test.ts | 69 ++++++++++++++++++- src/services/compact/compact.ts | 24 +++++-- 5 files changed, 176 insertions(+), 11 deletions(-) diff --git a/src/server/__tests__/session-messages-http.test.ts b/src/server/__tests__/session-messages-http.test.ts index ede4294b..c391c77a 100644 --- a/src/server/__tests__/session-messages-http.test.ts +++ b/src/server/__tests__/session-messages-http.test.ts @@ -137,6 +137,38 @@ afterEach(async () => { }) describe('session messages HTTP surface', () => { + it('keeps a transcript above 20 MiB readable when checkpoint previews exceed their budget (#1373)', async () => { + const sessionId = await seedSessionWithSubagent() + const filePath = path.join(tmpDir, 'projects', '-tmp-http-invariant', `${sessionId}.jsonl`) + const content = 'x'.repeat(8 * 1024) + const count = 2700 + await fs.appendFile(filePath, Array.from({ length: count }, (_, n) => JSON.stringify({ + type: 'assistant', uuid: `issue-1373-${n}`, timestamp: '2026-01-02T00:00:00Z', + message: { role: 'assistant', content }, + })).join('\n') + '\n') + expect((await fs.stat(filePath)).size).toBeGreaterThan(20 * 1024 * 1024) + + const checkpoint = await api('GET', `/api/sessions/${sessionId}/turn-checkpoints`) + expect(checkpoint.status).toBe(413) + expect((await checkpoint.json() as { error: string }).error).toBe('HISTORY_CHECKPOINT_PREVIEW_LIMIT') + + for (const suffix of ['', '/messages', '/messages?mode=full']) { + const response = await api('GET', `/api/sessions/${sessionId}${suffix}`) + expect(response.status).toBe(200) + const body = await response.json() as { + messages: Array<{ id: string; content: unknown }> + page: { historyComplete: boolean; hasMore: boolean } + } + expect(body.messages.at(-1)?.id).toBe(`issue-1373-${count - 1}`) + if (suffix.endsWith('mode=full')) { + expect(body.messages.filter(message => message.id.startsWith('issue-1373-'))).toHaveLength(count) + expect(body.page.historyComplete).toBe(true) + } else { + expect(body.page.hasMore).toBe(true) + } + } + }) + it('bounds both public history endpoints and returns a continuation without canonical hydration', async () => { const sessionId = await seedSessionWithSubagent() const filePath = path.join(tmpDir, 'projects', '-tmp-http-invariant', `${sessionId}.jsonl`) diff --git a/src/services/api/errors.test.ts b/src/services/api/errors.test.ts index 390caba7..226cefa5 100644 --- a/src/services/api/errors.test.ts +++ b/src/services/api/errors.test.ts @@ -3,10 +3,12 @@ import { APIError } from '@anthropic-ai/sdk' import { BUSINESS_ERROR_CODES } from '../../constants/businessErrors.js' import { getAssistantMessageFromError, + getPromptTooLongTokenGap, getImageUnsupportedErrorMessage, isContextOverflowErrorText, isUnsupportedImageInputErrorMessage, PROMPT_TOO_LONG_ERROR_MESSAGE, + parsePromptTooLongTokenCounts, } from './errors.js' describe('image unsupported API errors', () => { @@ -142,6 +144,50 @@ describe('image unsupported API errors', () => { }) describe('context overflow errors', () => { + test('uses the full requested DeepSeek token count to recover an oversized session (#1373)', () => { + const message = "This model's maximum context length is 1048576 tokens. However, you requested 3763011 tokens (3731011 in the messages, 32000 in the completion)." + const error = new APIError(400, { + error: { type: 'invalid_request_error', message }, + }, message, undefined) + const assistant = getAssistantMessageFromError(error, 'deepseek-v4-flash') + + expect(parsePromptTooLongTokenCounts(message)).toEqual({ + actualTokens: 3763011, + limitTokens: 1048576, + }) + expect(getPromptTooLongTokenGap(assistant)).toBe(2714435) + }) + + test('parses wrapped and case-insensitive Anthropic and OpenAI token counts', () => { + for (const message of [ + '400 {"error":{"message":"PROMPT IS TOO LONG: 137500 tokens > 135000 maximum"}}', + '400 {"error":{"message":"This model\'s MAXIMUM CONTEXT LENGTH IS 135000 tokens. However, you REQUESTED 137500 tokens."}}', + ]) { + expect(parsePromptTooLongTokenCounts(message)).toEqual({ + actualTokens: 137500, + limitTokens: 135000, + }) + } + }) + + test('leaves missing, malformed, and invalid token counts unparsed', () => { + for (const message of [ + 'Prompt is too long', + 'maximum context length is 1048576 tokens', + 'you requested 3763011 tokens', + 'maximum context length is -1 tokens. However, you requested 3 tokens.', + 'maximum context length is 1.5 tokens. However, you requested 3 tokens.', + 'maximum context length is 0 tokens. However, you requested 3 tokens.', + 'maximum context length is 1 tokens. However, you requested 9007199254740992 tokens.', + 'prompt is too long: 0 tokens > 135000 maximum', + ]) { + expect(parsePromptTooLongTokenCounts(message)).toEqual({ + actualTokens: undefined, + limitTokens: undefined, + }) + } + }) + test('matches provider-specific overflow wordings', () => { const overflowMessages = [ 'prompt is too long: 137500 tokens > 135000 maximum', diff --git a/src/services/api/errors.ts b/src/services/api/errors.ts index 95e411fa..cb6cb5e2 100644 --- a/src/services/api/errors.ts +++ b/src/services/api/errors.ts @@ -146,7 +146,8 @@ export function isPromptTooLongMessage(msg: AssistantMessage): boolean { /** * Parse actual/limit token counts from a raw prompt-too-long API error - * message like "prompt is too long: 137500 tokens > 135000 maximum". + * message in Anthropic or OpenAI-compatible format. Requested tokens include + * the completion allowance so compaction also leaves room for the response. * The raw string may be wrapped in SDK prefixes or JSON envelopes, or * have different casing (Vertex), so this is intentionally lenient. */ @@ -154,12 +155,19 @@ export function parsePromptTooLongTokenCounts(rawMessage: string): { actualTokens: number | undefined limitTokens: number | undefined } { - const match = rawMessage.match( + const anthropic = rawMessage.match( /prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)/i, ) + const openAI = anthropic ? null : rawMessage.match( + /maximum context length is\s+(\d+)\s+tokens?\.\s*(?:however,\s*)you requested\s+(\d+)\s+tokens?\b/i, + ) + const actualTokens = Number(anthropic?.[1] ?? openAI?.[2]) + const limitTokens = Number(anthropic?.[2] ?? openAI?.[1]) + const valid = Number.isSafeInteger(actualTokens) && actualTokens > 0 && + Number.isSafeInteger(limitTokens) && limitTokens > 0 return { - actualTokens: match ? parseInt(match[1]!, 10) : undefined, - limitTokens: match ? parseInt(match[2]!, 10) : undefined, + actualTokens: valid ? actualTokens : undefined, + limitTokens: valid ? limitTokens : undefined, } } diff --git a/src/services/compact/compact.test.ts b/src/services/compact/compact.test.ts index 4576be77..2983f463 100644 --- a/src/services/compact/compact.test.ts +++ b/src/services/compact/compact.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'bun:test' -import { buildPostCompactMessages, type CompactionResult } from './compact.js' +import { buildPostCompactMessages, truncateHeadForPTLRetry, type CompactionResult } from './compact.js' import { getCurrentUsage } from '../../utils/tokens.js' -import type { Message } from '../../types/message.js' +import type { AssistantMessage, Message } from '../../types/message.js' const PRE_COMPACT_USAGE = { input_tokens: 150_000, @@ -124,3 +124,68 @@ describe('buildPostCompactMessages stale-usage stripping (#743)', () => { expect(getCurrentUsage(result)).toBeNull() }) }) + +describe('oversized compaction recovery (#1373)', () => { + function toolHistory(rounds: number): Message[] { + const content = 'historical log data '.repeat(28_000) + const messages: Message[] = [makePreservedUser()] + for (let index = 0; index < rounds; index++) { + messages.push({ + ...makePreservedAssistant(), + uuid: crypto.randomUUID(), + message: { + id: `round-${index}`, role: 'assistant', model: 'deepseek-flash', + content: [{ type: 'tool_use', id: `read-${index}`, name: 'Read', input: { file_path: `/fixture/${index}` } }], + stop_reason: 'tool_use', usage: { input_tokens: 0, output_tokens: 0 }, + }, + } as Message, { + ...makePreservedUser(), + uuid: crypto.randomUUID(), + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: `read-${index}`, content }] }, + } as Message) + } + messages.push(makePreservedAssistant(), makePreservedUser()) + return messages + } + + function overflow(errorDetails: string): AssistantMessage { + return { + ...makePreservedAssistant(), isApiErrorMessage: true, errorDetails, + message: { ...(makePreservedAssistant() as AssistantMessage).message, + content: [{ type: 'text', text: 'Prompt is too long' }] }, + } as AssistantMessage + } + + test('fits the provider budget despite an overestimated local tokenizer, preserving recent tool pairs', () => { + const messages = toolHistory(44) + // Counts captured from the real DeepSeek request: chars/4 estimates this + // repeated log much higher than the provider. Subtracting its raw token + // gap from our estimate leaves too many rounds and exhausts the retries. + const error = overflow("This model's maximum context length is 1048576 tokens. However, you requested 3763011 tokens (3731011 in the messages, 32000 in the completion).") + const result = truncateHeadForPTLRetry(messages, error)! + const uses = result.flatMap(message => message.type === 'assistant' + ? message.message.content.filter(block => block.type === 'tool_use') : []) + const results = result.flatMap(message => message.type === 'user' && Array.isArray(message.message.content) + ? message.message.content.filter(block => block.type === 'tool_result') : []) + // Real fixture rounds cost about 84k tokens each, plus fixed request + // overhead. A retry must actually fit, not merely become smaller. + expect(62_000 + uses.length * 84_114).toBeLessThan(1_048_576) + expect(uses.length).toBeGreaterThan(0) + expect(uses.map(block => block.id)).toEqual(results.map(block => block.tool_use_id)) + expect(uses.at(-1)?.id).toBe('read-43') + expect(result.at(-1)).toBe(messages.at(-1)) + expect(messages).toHaveLength(91) + expect(result[0]?.type).toBe('user') + }) + + test('unparseable overflow still makes progress across retries and keeps the newest round', () => { + const messages = toolHistory(8) + const error = overflow('Provider rejected the prompt without token counts') + const first = truncateHeadForPTLRetry(messages, error)! + const second = truncateHeadForPTLRetry(first, error)! + expect(first.length).toBeLessThan(messages.length) + expect(second.length).toBeLessThan(first.length) + expect(second.at(-1)).toBe(messages.at(-1)) + expect(second[0]?.type).toBe('user') + }) +}) diff --git a/src/services/compact/compact.ts b/src/services/compact/compact.ts index fad233e4..29756e16 100644 --- a/src/services/compact/compact.ts +++ b/src/services/compact/compact.ts @@ -101,7 +101,8 @@ import { queryModelWithStreaming, } from '../api/claude.js' import { - getPromptTooLongTokenGap, + isPromptTooLongMessage, + parsePromptTooLongTokenCounts, PROMPT_TOO_LONG_ERROR_MESSAGE, startsWithApiErrorPrefix, } from '../api/errors.js' @@ -228,7 +229,9 @@ const MAX_PTL_RETRIES = 3 const PTL_RETRY_MARKER = '[earlier conversation truncated for compaction retry]' /** - * Drops the oldest API-round groups from messages until tokenGap is covered. + * Drops the oldest API-round groups to fit the provider's reported budget. + * Local token estimates only weight the groups: their absolute scale can be + * very different from the provider's tokenizer, especially for large logs. * Falls back to dropping 20% of groups when the gap is unparseable (some * Vertex/Bedrock error formats). Returns null when nothing can be dropped * without leaving an empty summarize set. @@ -257,15 +260,26 @@ export function truncateHeadForPTLRetry( const groups = groupMessagesByApiRound(input) if (groups.length < 2) return null - const tokenGap = getPromptTooLongTokenGap(ptlResponse) + const { actualTokens, limitTokens } = parsePromptTooLongTokenCounts(ptlResponse.errorDetails ?? '') let dropCount: number - if (tokenGap !== undefined) { + if ( + isPromptTooLongMessage(ptlResponse) && + actualTokens !== undefined && + limitTokens !== undefined && + actualTokens > limitTokens + ) { + // Reserve room for the summary prompt, fixed request overhead and rounding + // at API-round boundaries. Subtracting an actual token gap directly from + // chars/4 estimates can leave oversized DeepSeek histories over limit + // even after retries (3.76M requested tokens for a 1.05M window in #1373). + const estimatedTokens = roughTokenCountEstimationForMessages(input) + const tokensToDrop = estimatedTokens * (1 - (limitTokens * 0.9) / actualTokens) let acc = 0 dropCount = 0 for (const g of groups) { acc += roughTokenCountEstimationForMessages(g) dropCount++ - if (acc >= tokenGap) break + if (acc >= tokensToDrop) break } } else { dropCount = Math.max(1, Math.floor(groups.length * 0.2))