diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index c9e800f29..1c366f190 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -542,9 +542,14 @@ For `dev:atomic-chat`, make sure Atomic Chat is running with a model loaded befo ## Message-Count Compaction Threshold -By default, OpenClaude compacts conversations based on token usage. A secondary -message-count-based trigger (`OPENCLAUDE_MAX_ACTIVE_MESSAGES`) exists for -diagnostics but is disabled by default. +By default, OpenClaude compacts conversations based on token usage and also +applies a safety hard cap of 1000 active messages. The hard cap catches long +sessions that accumulate many small messages with negligible token cost. + +This hard cap is a safety net: it can still trigger compaction even when +`DISABLE_COMPACT`, `DISABLE_AUTO_COMPACT`, or a disabled auto-compact setting +would otherwise prevent it. Set `OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP=0` +only when you need to suppress that safety cap for diagnostics. If you frequently resume long sessions that accumulate hundreds of small tool-result messages with negligible token cost, you can opt in to message-count @@ -555,10 +560,26 @@ compaction via the in-app `/config` command: ``` Select **Message-count compaction** and choose a threshold (`100`, `200`, `500`, -or `1000`). Setting it to `off` (default) disables the message-count trigger. +or `1000`). Setting it to `off` (default) leaves only the built-in hard cap. This setting is intended for power users debugging specific edge cases. Most users should leave it at `off`. The legacy `OPENCLAUDE_MAX_ACTIVE_MESSAGES` environment variable is still -honored when the setting is `off`. +honored when the setting is `off`. `OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP` +can override the safety cap; set it to `0` only for diagnostics. + +### Long-session memory guard validation + +For changes that touch auto-compact, provider request conversion, transcript +retention, or in-process teammates, run the focused long-session guard checks: + +```bash +bun test --feature=UNATTENDED_RETRY src/query/autoCompactCooldown.test.ts src/utils/maxActiveMessages.test.ts src/services/api/openaiShim.test.ts +``` + +These tests cover repeated over-cap turns, auto-compact cooldown blocking, +teammate active-message compaction, malformed hard-cap overrides, and +pruned-history tool-call/tool-result pairing. They are not a substitute for a +multi-hour manual soak, but they pin the bounded-history and conversion +invariants that previously let long sessions grow until Node/V8 OOM. diff --git a/scripts/system-check.test.ts b/scripts/system-check.test.ts index 9cd242a22..ec60ee1fe 100644 --- a/scripts/system-check.test.ts +++ b/scripts/system-check.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { + buildMemoryGuardChecks, buildSandboxRuntimeCheck, checkOpenAIEnv, checkNodeVersion, @@ -9,6 +10,7 @@ import { readNodeExecutableVersion, serializeSafeEnvSummary, } from './system-check.ts' +import { DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP } from '../src/utils/maxActiveMessages.ts' const ENV_KEYS = [ 'CLAUDE_CODE_USE_OPENAI', @@ -31,6 +33,11 @@ const ENV_KEYS = [ 'CODEX_API_KEY', 'CODEX_AUTH_JSON_PATH', 'CODEX_HOME', + 'DISABLE_COMPACT', + 'DISABLE_AUTO_COMPACT', + 'OPENCLAUDE_MAX_ACTIVE_MESSAGES', + 'OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP', + 'OPENCLAUDE_MAX_MEMORY_MB', ] as const const originalEnv: Record = {} @@ -259,6 +266,103 @@ describe('system-check provider diagnostics', () => { }) }) +describe('system-check memory guard diagnostics', () => { + test('reports safe default auto-compact and hard-cap guards', () => { + const results = buildMemoryGuardChecks({ + autoCompactEnabled: true, + maxMessagesCompactionThreshold: undefined, + env: {}, + }) + + expect(results).toContainEqual({ + ok: true, + label: 'Auto-compact guard', + detail: `Enabled; message-count threshold off; hard cap ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP}.`, + }) + expect(results).toContainEqual({ + ok: true, + label: 'Active-message hard cap', + detail: `Active at ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP} messages (default; malformed overrides fall back to ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP}).`, + }) + expect(results.find(result => result.label === 'Memory pressure guard')) + .toMatchObject({ ok: true }) + }) + + test('falls back to the default hard cap when the override is malformed', () => { + const results = buildMemoryGuardChecks({ + autoCompactEnabled: true, + maxMessagesCompactionThreshold: undefined, + env: { + OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP: 'not-a-number', + }, + }) + + expect(results).toContainEqual({ + ok: true, + label: 'Active-message hard cap', + detail: `Active at ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP} messages; malformed override fell back to ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP}.`, + }) + }) + + test('reports valid custom hard-cap overrides without fallback wording', () => { + const results = buildMemoryGuardChecks({ + autoCompactEnabled: true, + maxMessagesCompactionThreshold: undefined, + env: { + OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP: '500', + }, + }) + + expect(results).toContainEqual({ + ok: true, + label: 'Active-message hard cap', + detail: 'Active at 500 messages.', + }) + }) + + test('fails when auto-compact is disabled by settings or env flags', () => { + const results = buildMemoryGuardChecks({ + autoCompactEnabled: false, + maxMessagesCompactionThreshold: '500', + env: { + DISABLE_COMPACT: '1', + DISABLE_AUTO_COMPACT: 'true', + }, + }) + + expect(results[0]).toEqual({ + ok: false, + label: 'Auto-compact guard', + detail: + 'settings disabled; DISABLE_COMPACT is set; DISABLE_AUTO_COMPACT is set', + }) + }) + + test('fails when active-message hard cap is explicitly disabled', () => { + const results = buildMemoryGuardChecks({ + autoCompactEnabled: true, + maxMessagesCompactionThreshold: '100', + env: { + OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP: '0', + OPENCLAUDE_MAX_MEMORY_MB: '4096', + }, + }) + + expect(results).toContainEqual({ + ok: false, + label: 'Active-message hard cap', + detail: + 'Disabled by OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP=0; long sessions can grow without the active-message safety cap.', + }) + expect(results).toContainEqual({ + ok: true, + label: 'Memory pressure guard', + detail: + 'Per-session budget 4096MB; elevated/critical compaction thresholds are derived from this budget at runtime.', + }) + }) +}) + describe('checkNodeVersion', () => { test('reads the Node.js version from the node executable output', () => { const probe = readNodeExecutableVersion(() => ({ diff --git a/scripts/system-check.ts b/scripts/system-check.ts index 8eb7aec3f..eb75cd233 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -32,6 +32,10 @@ import { checkSupportedNodeVersion, } from '../src/utils/nodeRuntime.js' import { SandboxManager } from '../src/utils/sandbox/sandbox-adapter.js' +import { + DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP, + getMaxActiveMessagesHardCap, +} from '../src/utils/maxActiveMessages.js' type CheckResult = { ok: boolean @@ -39,6 +43,12 @@ type CheckResult = { detail?: string } +type MemoryGuardConfigInput = { + autoCompactEnabled: boolean + maxMessagesCompactionThreshold?: string + env?: NodeJS.ProcessEnv +} + type NodeExecutableVersionProbe = | { ok: true @@ -68,6 +78,85 @@ function isTruthy(value: string | undefined): boolean { return normalized !== '' && normalized !== '0' && normalized !== 'false' && normalized !== 'no' } +function parsePositiveInteger(value: string | undefined): number { + if (!value) return 0 + const trimmed = value.trim() + if (!/^[1-9]\d*$/.test(trimmed)) return 0 + const parsed = Number.parseInt(trimmed, 10) + return Number.isSafeInteger(parsed) ? parsed : 0 +} + +function formatActiveHardCapDetail( + hardCap: number, + rawOverride: string | undefined, +): string { + if (rawOverride === undefined) { + return `Active at ${hardCap} messages (default; malformed overrides fall back to ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP}).` + } + if (parsePositiveInteger(rawOverride) > 0) { + return `Active at ${hardCap} messages.` + } + return `Active at ${hardCap} messages; malformed override fell back to ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP}.` +} + +export function buildMemoryGuardChecks( + input: MemoryGuardConfigInput, +): CheckResult[] { + const env = input.env ?? process.env + const results: CheckResult[] = [] + const disableCompact = isTruthy(env.DISABLE_COMPACT) + const disableAutoCompact = isTruthy(env.DISABLE_AUTO_COMPACT) + const autoCompactAvailable = + input.autoCompactEnabled && !disableCompact && !disableAutoCompact + const hardCapOverride = env.OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP + const hardCap = getMaxActiveMessagesHardCap(env) + const configuredLimit = + input.maxMessagesCompactionThreshold && + input.maxMessagesCompactionThreshold !== 'off' + ? input.maxMessagesCompactionThreshold + : undefined + const legacyLimit = parsePositiveInteger(env.OPENCLAUDE_MAX_ACTIVE_MESSAGES) + const memoryBudget = parsePositiveInteger(env.OPENCLAUDE_MAX_MEMORY_MB) || 1536 + + results.push( + autoCompactAvailable + ? pass( + 'Auto-compact guard', + `Enabled; message-count threshold ${configuredLimit ?? (legacyLimit > 0 ? legacyLimit : 'off')}; hard cap ${hardCap === 0 ? 'disabled' : hardCap}.`, + ) + : fail( + 'Auto-compact guard', + [ + input.autoCompactEnabled ? undefined : 'settings disabled', + disableCompact ? 'DISABLE_COMPACT is set' : undefined, + disableAutoCompact ? 'DISABLE_AUTO_COMPACT is set' : undefined, + ].filter(Boolean).join('; ') || + 'Disabled by configuration.', + ), + ) + + results.push( + hardCap === 0 + ? fail( + 'Active-message hard cap', + 'Disabled by OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP=0; long sessions can grow without the active-message safety cap.', + ) + : pass( + 'Active-message hard cap', + formatActiveHardCapDetail(hardCap, hardCapOverride), + ), + ) + + results.push( + pass( + 'Memory pressure guard', + `Per-session budget ${memoryBudget}MB; elevated/critical compaction thresholds are derived from this budget at runtime.`, + ), + ) + + return results +} + function parseOptions(argv: string[]): CliOptions { const options: CliOptions = { json: false, @@ -895,8 +984,8 @@ async function main(): Promise { const options = parseOptions(process.argv.slice(2)) const results: CheckResult[] = [] - const { enableConfigs } = await import('../src/utils/config.js') - enableConfigs() + const configModule = await import('../src/utils/config.js') + configModule.enableConfigs() const { applySafeConfigEnvironmentVariables } = await import('../src/utils/managedEnv.js') applySafeConfigEnvironmentVariables() const { hydrateGithubModelsTokenFromSecureStorage } = await import('../src/utils/githubModelsCredentials.js') @@ -906,6 +995,14 @@ async function main(): Promise { results.push(checkBunRuntime()) results.push(checkBuildArtifacts()) results.push(checkSandboxRuntime()) + const globalConfig = configModule.getGlobalConfig() + results.push( + ...buildMemoryGuardChecks({ + autoCompactEnabled: globalConfig.autoCompactEnabled, + maxMessagesCompactionThreshold: + globalConfig.maxMessagesCompactionThreshold, + }), + ) results.push(...checkOpenAIEnv()) results.push(await checkBaseUrlReachability()) results.push(await checkProviderGenerationReadiness()) diff --git a/src/query.ts b/src/query.ts index 9bf594c4f..70cf69019 100644 --- a/src/query.ts +++ b/src/query.ts @@ -73,6 +73,10 @@ import { getAttachmentMessages, startRelevantMemoryPrefetch, } from './utils/attachments.js' +import { + isAboveMaxActiveMessagesLimit, + resolveMaxActiveMessagesLimit, +} from './utils/maxActiveMessages.js' /* eslint-disable @typescript-eslint/no-require-imports */ const skillPrefetch = feature('EXPERIMENTAL_SKILL_SEARCH') ? (require('./services/skillSearch/prefetch.js') as typeof import('./services/skillSearch/prefetch.js')) @@ -313,6 +317,45 @@ function formatAutoCompactRetryDelay(delayMs: number): string { return `${totalMinutes} minute${totalMinutes === 1 ? '' : 's'}` } +function createAutoCompactDiagnosticMessage(args: { + consecutiveFailures?: number + nextRetryAtMs?: number + circuitBreakerActive?: boolean + circuitBreakerTripped?: boolean +}): Message | undefined { + const { + consecutiveFailures, + nextRetryAtMs, + circuitBreakerActive, + circuitBreakerTripped, + } = args + + if (circuitBreakerActive || circuitBreakerTripped) { + const retryDelayMs = + nextRetryAtMs !== undefined ? nextRetryAtMs - Date.now() : undefined + const retryText = + retryDelayMs !== undefined && retryDelayMs > 0 + ? ` It will retry after ${formatAutoCompactRetryDelay(retryDelayMs)}.` + : '' + return createSystemMessage( + `Automatic compaction is paused after repeated failures.${retryText} OpenClaude will stop before sending oversized requests while the guard is active.`, + 'warning', + ) + } + + if ( + consecutiveFailures !== undefined && + consecutiveFailures > 0 + ) { + return createSystemMessage( + `Automatic compaction failed (${consecutiveFailures}/${MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES}); OpenClaude will retry compaction on the next eligible turn.`, + 'warning', + ) + } + + return undefined +} + /** * Is this a max_output_tokens error message? If so, the streaming loop should * withhold it from SDK callers until we know whether the recovery loop can @@ -328,6 +371,33 @@ function isWithheldMaxOutputTokens( return msg?.type === 'assistant' && msg.apiError === 'max_output_tokens' } +function isWithheldContextOverflow( + msg: Message | StreamEvent | undefined, +): msg is AssistantMessage { + return msg?.type === 'assistant' && msg.apiError === 'context_overflow' +} + +function shouldRecoverContextOverflow( + msg: Message | StreamEvent | undefined, + hasAttemptedContextOverflowRecovery: boolean, + querySource: QuerySource, +): boolean { + return ( + !hasAttemptedContextOverflowRecovery && + querySource !== 'compact' && + querySource !== 'session_memory' && + isWithheldContextOverflow(msg) + ) +} + +function createContextOverflowRecoveryMessage(): UserMessage { + return createUserMessage({ + content: + 'The previous provider request exceeded the context window. OpenClaude compacted the conversation and is retrying this turn once; continue from the compacted context, avoid repeating the oversized request shape, and use narrower tool reads if more detail is needed.', + isMeta: true, + }) +} + function isWithheldProviderMaxTokensCap( msg: Message | StreamEvent | undefined, ): msg is AssistantMessage { @@ -371,6 +441,7 @@ type State = { autoCompactTracking: AutoCompactTrackingState | undefined maxOutputTokensRecoveryCount: number hasAttemptedReactiveCompact: boolean + hasAttemptedContextOverflowRecovery: boolean maxOutputTokensOverride: number | undefined providerMaxOutputTokensCap: number | undefined pendingToolUseSummary: Promise | undefined @@ -458,6 +529,7 @@ async function* queryLoop( stopHookActive: undefined, maxOutputTokensRecoveryCount: 0, hasAttemptedReactiveCompact: false, + hasAttemptedContextOverflowRecovery: false, hasAttemptedProviderFallback: false, turnCount: 1, continuationNudgeCount: 0, @@ -509,6 +581,7 @@ async function* queryLoop( autoCompactTracking, maxOutputTokensRecoveryCount, hasAttemptedReactiveCompact, + hasAttemptedContextOverflowRecovery, hasAttemptedProviderFallback, maxOutputTokensOverride, providerMaxOutputTokensCap, @@ -708,16 +781,19 @@ async function* queryLoop( // compaction and forcing would deadlock via recursive autocompaction. const canForceCompact = querySource !== 'compact' && querySource !== 'session_memory' + const activeMessageLimit = canForceCompact + ? resolveMaxActiveMessagesLimit( + maxMessagesCompactionThreshold, + process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES, + ) + : 0 if (canForceCompact) { - const configSetting = maxMessagesCompactionThreshold - const envSetting = process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES - const maxActiveMessages = configSetting !== 'off' - ? Number.parseInt(configSetting, 10) - : envSetting - ? Number.parseInt(envSetting, 10) - : 0 - - if (maxActiveMessages > 0 && messagesForQuery.length > maxActiveMessages) { + if ( + isAboveMaxActiveMessagesLimit( + messagesForQuery.length, + activeMessageLimit, + ) + ) { tracking = { ...(tracking ?? { compacted: false, turnId: '', turnCounter: 0 }), forceReason: 'message-count', @@ -815,13 +891,17 @@ async function* queryLoop( updateAutoCompactTracking(tracking) const postCompactMessages = buildPostCompactMessages(compactionResult) + const messagesAfterCompact = + state.transition?.reason === 'context_overflow_compact_retry' + ? [...postCompactMessages, createContextOverflowRecoveryMessage()] + : postCompactMessages for (const message of postCompactMessages) { yield message } // Continue on with the current query call using the post compact messages - messagesForQuery = postCompactMessages + messagesForQuery = messagesAfterCompact } else if ( consecutiveFailures !== undefined || nextRetryAtMs !== undefined || @@ -847,6 +927,16 @@ async function* queryLoop( } tracking = nextTracking updateAutoCompactTracking(tracking) + + const diagnosticMessage = createAutoCompactDiagnosticMessage({ + consecutiveFailures, + nextRetryAtMs, + circuitBreakerActive, + circuitBreakerTripped, + }) + if (diagnosticMessage) { + yield diagnosticMessage + } } //TODO: no need to set toolUseContext.messages during set-up since it is updated here @@ -961,10 +1051,23 @@ async function* queryLoop( } } + if ( + state.transition?.reason === 'context_overflow_compact_retry' && + !compactionResult + ) { + yield createAssistantAPIErrorMessage({ + content: + 'The provider reported a context-window overflow, but automatic compaction could not reduce the conversation before retry. Run /compact, undo recent large output, or start a new session with /new.', + apiError: 'context_overflow', + error: 'invalid_request', + }) + return { reason: 'blocking_limit' } + } + // Safety net: when auto-compact's circuit breaker has tripped, the normal // blocking check above may be gated on reactiveCompact. If compaction is - // cooling down or otherwise exhausted and context is still over the - // autocompact threshold, block immediately with a clear message instead + // cooling down or otherwise exhausted and context or message count is still + // over the safety threshold, block immediately with a clear message instead // of burning an oversized API call. if ( tracking?.consecutiveFailures !== undefined && @@ -983,7 +1086,11 @@ async function* queryLoop( const isAboveBreakerThreshold = isAboveAutoCompactThreshold || ((circuitBreakerActive === true || circuitBreakerTripped === true) && - tokenUsage >= getAutoCompactThreshold(model)) + tokenUsage >= getAutoCompactThreshold(model)) || + isAboveMaxActiveMessagesLimit( + messagesForQuery.length, + activeMessageLimit, + ) if (isAboveBreakerThreshold) { const nowMs = Date.now() const retryDelayMs = @@ -992,10 +1099,10 @@ async function* queryLoop( : undefined const content = retryDelayMs !== undefined && retryDelayMs > 0 - ? 'The conversation is over the auto-compact threshold, but automatic compaction is cooling down after repeated failures. ' + + ? 'The conversation is over the auto-compact safety threshold, but automatic compaction is cooling down after repeated failures. ' + 'OpenClaude stopped before sending another oversized request. ' + `Retry after ${formatAutoCompactRetryDelay(retryDelayMs)}, run /compact, or start a new session with /new.` - : 'The conversation is over the auto-compact threshold and automatic compaction has failed repeatedly. ' + + : 'The conversation is over the auto-compact safety threshold and automatic compaction has failed repeatedly. ' + 'OpenClaude stopped before sending another oversized request. Run /compact, undo recent large tool output, or start a new session with /new.' yield createAssistantAPIErrorMessage({ content, @@ -1005,6 +1112,17 @@ async function* queryLoop( } } + if ( + isAboveMaxActiveMessagesLimit(messagesForQuery.length, activeMessageLimit) + ) { + yield createAssistantAPIErrorMessage({ + content: + 'The conversation is over the active-message safety limit, but automatic compaction could not reduce it before the next provider request. OpenClaude stopped before sending another oversized request. Run /compact, undo recent large tool output, or start a new session with /new.', + error: 'invalid_request', + }) + return { reason: 'blocking_limit' } + } + let attemptWithFallback = true const toolsForModel = agentStepLimit?.summaryRequested ? [] @@ -1186,6 +1304,15 @@ async function* queryLoop( if (isWithheldMaxOutputTokens(message)) { withheld = true } + if ( + shouldRecoverContextOverflow( + message, + hasAttemptedContextOverflowRecovery, + querySource, + ) + ) { + withheld = true + } if (isWithheldProviderMaxTokensCap(message)) { withheld = true } @@ -1491,6 +1618,7 @@ async function* queryLoop( autoCompactTracking: tracking, maxOutputTokensRecoveryCount, hasAttemptedReactiveCompact, + hasAttemptedContextOverflowRecovery, hasAttemptedProviderFallback, maxOutputTokensOverride: undefined, providerMaxOutputTokensCap, @@ -1549,6 +1677,7 @@ async function* queryLoop( autoCompactTracking: undefined, maxOutputTokensRecoveryCount, hasAttemptedReactiveCompact: true, + hasAttemptedContextOverflowRecovery, hasAttemptedProviderFallback, maxOutputTokensOverride: undefined, providerMaxOutputTokensCap, @@ -1580,6 +1709,42 @@ async function* queryLoop( return { reason: 'prompt_too_long' } } + if ( + shouldRecoverContextOverflow( + lastMessage, + hasAttemptedContextOverflowRecovery, + querySource, + ) + ) { + yield createSystemMessage( + 'Provider context limit reached; compacting conversation and retrying turn.', + 'warning', + ) + const nextTracking: AutoCompactTrackingState = { + ...(tracking ?? { compacted: false, turnId: '', turnCounter: 0 }), + forceReason: 'memory-pressure', + } + const next: State = { + messages: messagesForQuery, + toolUseContext, + autoCompactTracking: nextTracking, + maxOutputTokensRecoveryCount, + hasAttemptedReactiveCompact, + hasAttemptedContextOverflowRecovery: true, + hasAttemptedProviderFallback, + maxOutputTokensOverride: undefined, + providerMaxOutputTokensCap, + pendingToolUseSummary: undefined, + stopHookActive: undefined, + turnCount, + continuationNudgeCount: state.continuationNudgeCount, + agentStepLimit, + transition: { reason: 'context_overflow_compact_retry' }, + } + state = next + continue + } + if (isWithheldProviderMaxTokensCap(lastMessage)) { const providerMaxTokensCap = getProviderMaxTokensCapFromMessage(lastMessage) @@ -1611,6 +1776,7 @@ async function* queryLoop( autoCompactTracking: tracking, maxOutputTokensRecoveryCount, hasAttemptedReactiveCompact, + hasAttemptedContextOverflowRecovery, hasAttemptedProviderFallback, maxOutputTokensOverride, providerMaxOutputTokensCap: nextProviderMaxOutputTokensCap, @@ -1659,6 +1825,7 @@ async function* queryLoop( autoCompactTracking: tracking, maxOutputTokensRecoveryCount, hasAttemptedReactiveCompact, + hasAttemptedContextOverflowRecovery, hasAttemptedProviderFallback, maxOutputTokensOverride: ESCALATED_MAX_TOKENS, providerMaxOutputTokensCap, @@ -1691,6 +1858,7 @@ async function* queryLoop( autoCompactTracking: tracking, maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1, hasAttemptedReactiveCompact, + hasAttemptedContextOverflowRecovery, hasAttemptedProviderFallback, maxOutputTokensOverride: undefined, providerMaxOutputTokensCap, @@ -1771,6 +1939,7 @@ async function* queryLoop( autoCompactTracking: tracking, maxOutputTokensRecoveryCount, hasAttemptedReactiveCompact, + hasAttemptedContextOverflowRecovery, hasAttemptedProviderFallback: true, maxOutputTokensOverride: undefined, providerMaxOutputTokensCap: undefined, @@ -1835,6 +2004,7 @@ async function* queryLoop( // here caused an infinite loop: compact → still too long → error → // stop hook blocking → compact → … burning thousands of API calls. hasAttemptedReactiveCompact, + hasAttemptedContextOverflowRecovery, // Same logic for the provider-fallback guard — a stop-hook blocking // error after a fallback switch is unrelated to which provider is // active, so preserve rather than re-fall-back. @@ -1878,6 +2048,7 @@ async function* queryLoop( autoCompactTracking: tracking, maxOutputTokensRecoveryCount: 0, hasAttemptedReactiveCompact: false, + hasAttemptedContextOverflowRecovery: false, hasAttemptedProviderFallback: false, maxOutputTokensOverride: undefined, providerMaxOutputTokensCap, @@ -1947,6 +2118,7 @@ async function* queryLoop( autoCompactTracking: tracking, maxOutputTokensRecoveryCount: 0, hasAttemptedReactiveCompact: false, + hasAttemptedContextOverflowRecovery: false, hasAttemptedProviderFallback: false, maxOutputTokensOverride: undefined, providerMaxOutputTokensCap, @@ -2496,6 +2668,7 @@ async function* queryLoop( turnCount: nextTurnCount, maxOutputTokensRecoveryCount: 0, hasAttemptedReactiveCompact: false, + hasAttemptedContextOverflowRecovery: false, hasAttemptedProviderFallback: false, continuationNudgeCount: 0, pendingToolUseSummary: nextPendingToolUseSummary, diff --git a/src/query/autoCompactCooldown.test.ts b/src/query/autoCompactCooldown.test.ts index 8a8ef76f5..9a91385c4 100644 --- a/src/query/autoCompactCooldown.test.ts +++ b/src/query/autoCompactCooldown.test.ts @@ -8,6 +8,7 @@ import { releaseSharedMutationLock, } from '../test/sharedMutationLock.js' import type { Message } from '../types/message.js' +import { createCompactBoundaryMessage } from '../utils/messages.js' import { asSystemPrompt } from '../utils/systemPromptType.js' import type { MaxMessagesCompactionThreshold } from '../utils/config.js' import type { QueryDeps } from './deps.js' @@ -39,6 +40,8 @@ const SAVED_ENV = { DISABLE_AUTO_COMPACT: process.env.DISABLE_AUTO_COMPACT, DISABLE_COMPACT: process.env.DISABLE_COMPACT, OPENCLAUDE_MAX_ACTIVE_MESSAGES: process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES, + OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP: + process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP, } let savedGlobalConfig: @@ -71,6 +74,7 @@ beforeEach(async () => { delete process.env.DISABLE_AUTO_COMPACT delete process.env.DISABLE_COMPACT delete process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES + delete process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP }) afterEach(() => { @@ -116,6 +120,27 @@ function overAutoCompactThresholdMessage(): Message { return userMessage('x'.repeat((threshold + 1_000) * 4)) } +function manySmallMessages(count: number): Message[] { + return Array.from({ length: count }, (_, index) => userMessage(`small-${index}`)) +} + +function compactedResult() { + return { + wasCompacted: true, + consecutiveFailures: 0, + compactionResult: { + boundaryMarker: createCompactBoundaryMessage('auto', 10_000), + summaryMessages: [userMessage('compacted summary')], + messagesToKeep: [], + attachments: [], + hookResults: [], + preCompactTokenCount: 10_000, + postCompactTokenCount: 500, + truePostCompactTokenCount: 500, + }, + } +} + function toolUseContext() { const abortController = new AbortController() return { @@ -237,6 +262,51 @@ async function runSuccessfulQuery( ) } +async function runMessageCountHardCapQuery(messages: Message[]) { + const seenTracking: Array = [] + const callModel = mock(async function* () { + yield assistantToolUseMessage() + }) + const deps: QueryDeps = { + callModel: callModel as QueryDeps['callModel'], + microcompact: mock(async (input: Message[]) => ({ + messages: input, + })) as QueryDeps['microcompact'], + autocompact: mock( + async ( + _messages: AutocompactArgs[0], + _toolUseContext: AutocompactArgs[1], + _params: AutocompactArgs[2], + _querySource: AutocompactArgs[3], + tracking: AutocompactArgs[4], + ) => { + seenTracking.push(tracking) + return tracking?.forceReason === 'message-count' + ? compactedResult() + : { wasCompacted: false } + }, + ) as QueryDeps['autocompact'], + uuid: () => 'test-uuid', + } + + const { query } = await loadQuery() + const result = await drain( + query({ + messages, + systemPrompt: asSystemPrompt([]), + userContext: {}, + systemContext: {}, + canUseTool, + toolUseContext: toolUseContext(), + querySource: 'repl_main_thread', + maxTurns: 1, + deps, + }), + ) + + return { ...result, callModel, seenTracking } +} + test('explicit off skips automatic microcompact during query flow', async () => { saveGlobalConfig(current => ({ ...current, @@ -289,6 +359,184 @@ test('numeric message-count threshold keeps automatic microcompact behavior', as expect(microcompact).toHaveBeenCalledTimes(1) }) +test('default active-message hard cap forces compaction', async () => { + const { terminal, callModel, seenTracking } = + await runMessageCountHardCapQuery(manySmallMessages(1001)) + + expect(terminal.reason).toBe('max_turns') + expect(callModel).toHaveBeenCalledTimes(1) + expect(seenTracking[0]?.forceReason).toBe('message-count') +}) + +test('long-session smoke keeps repeated over-cap turns bounded before provider calls', async () => { + const seenProviderMessageCounts: number[] = [] + const seenTracking: Array = [] + const callModel = mock(async function* (params: { messages: Message[] }) { + seenProviderMessageCounts.push(params.messages.length) + yield assistantToolUseMessage() + }) + const deps: QueryDeps = { + callModel: callModel as QueryDeps['callModel'], + microcompact: mock(async (input: Message[]) => ({ + messages: input, + })) as QueryDeps['microcompact'], + autocompact: mock( + async ( + _messages: AutocompactArgs[0], + _toolUseContext: AutocompactArgs[1], + _params: AutocompactArgs[2], + _querySource: AutocompactArgs[3], + tracking: AutocompactArgs[4], + ) => { + seenTracking.push(tracking) + return tracking?.forceReason === 'message-count' + ? compactedResult() + : { wasCompacted: false } + }, + ) as QueryDeps['autocompact'], + uuid: () => 'test-uuid', + } + const { query } = await loadQuery() + let persistedTracking: AutoCompactTrackingState | undefined + + for (let turn = 0; turn < 4; turn++) { + const result = await drain( + query({ + messages: manySmallMessages(1001 + turn), + systemPrompt: asSystemPrompt([]), + userContext: {}, + systemContext: {}, + canUseTool, + toolUseContext: toolUseContext(), + querySource: 'repl_main_thread', + maxTurns: 1, + deps, + autoCompactTracking: persistedTracking, + onAutoCompactTrackingChange: tracking => { + persistedTracking = tracking + }, + }), + ) + expect(result.terminal.reason).toBe('max_turns') + } + + expect(callModel).toHaveBeenCalledTimes(4) + expect(seenTracking).toHaveLength(4) + expect( + seenTracking.every(tracking => tracking?.forceReason === 'message-count'), + ).toBe(true) + expect(seenProviderMessageCounts.every(count => count <= 1000)).toBe(true) +}) + +test('invalid active-message hard cap override keeps default safety cap', async () => { + process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP = '100O' + + const { terminal, callModel, seenTracking } = + await runMessageCountHardCapQuery(manySmallMessages(1001)) + + expect(terminal.reason).toBe('max_turns') + expect(callModel).toHaveBeenCalledTimes(1) + expect(seenTracking[0]?.forceReason).toBe('message-count') +}) + +test('explicit zero active-message hard cap override disables safety cap', async () => { + process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP = '0' + + const { terminal, callModel, seenTracking } = + await runMessageCountHardCapQuery(manySmallMessages(1001)) + + expect(terminal.reason).toBe('max_turns') + expect(callModel).toHaveBeenCalledTimes(1) + expect(seenTracking[0]?.forceReason).toBeUndefined() +}) + +test('active-message hard cap blocks when forced compaction fails', async () => { + const messages = manySmallMessages(1001) + const callModel = mock(() => { + throw new Error('model should not be called while over the hard cap') + }) + const deps: QueryDeps = { + callModel: callModel as QueryDeps['callModel'], + microcompact: mock(async (input: Message[]) => ({ + messages: input, + })) as QueryDeps['microcompact'], + autocompact: mock(async () => ({ + wasCompacted: false, + })) as QueryDeps['autocompact'], + uuid: () => 'test-uuid', + } + + const { query } = await loadQuery() + const { yielded, terminal } = await drain( + query({ + messages, + systemPrompt: asSystemPrompt([]), + userContext: {}, + systemContext: {}, + canUseTool, + toolUseContext: toolUseContext(), + querySource: 'repl_main_thread', + deps, + }), + ) + + expect(callModel).not.toHaveBeenCalled() + expect(terminal.reason).toBe('blocking_limit') + const apiError = yielded.find( + (message): message is Message => + (message as { isApiErrorMessage?: boolean }).isApiErrorMessage === true, + ) + expect(apiError).toBeDefined() + const text = apiError!.message.content[0].text + expect(text).toContain('active-message safety limit') + expect(text).toContain('stopped before sending another oversized request') +}) + +test('auto-compact failure emits a visible warning before retrying later', async () => { + const messages = [overAutoCompactThresholdMessage()] + const callModel = mock(async function* () { + yield assistantToolUseMessage() + }) + const deps: QueryDeps = { + callModel: callModel as QueryDeps['callModel'], + microcompact: mock(async (input: Message[]) => ({ + messages: input, + })) as QueryDeps['microcompact'], + autocompact: mock(async () => ({ + wasCompacted: false, + consecutiveFailures: 1, + lastFailureAtMs: Date.now(), + })) as QueryDeps['autocompact'], + uuid: () => 'test-uuid', + } + + const { query } = await loadQuery() + const { yielded, terminal } = await drain( + query({ + messages, + systemPrompt: asSystemPrompt([]), + userContext: {}, + systemContext: {}, + canUseTool, + toolUseContext: toolUseContext(), + querySource: 'repl_main_thread', + maxTurns: 1, + deps, + }), + ) + + expect(callModel).toHaveBeenCalledTimes(1) + expect(terminal.reason).toBe('max_turns') + const warning = yielded.find( + message => + message.type === 'system' && + message.subtype === 'informational' && + message.level === 'warning', + ) + expect(warning?.content).toContain('Automatic compaction failed (1/3)') + expect(warning?.content).toContain('retry compaction') +}) + test('explicit compact query source still runs microcompact when threshold is off', async () => { saveGlobalConfig(current => ({ ...current, @@ -356,6 +604,61 @@ test('active auto-compact cooldown blocks before model call with cooldown guidan const text = apiError!.message.content[0].text expect(text).toContain('automatic compaction is cooling down') expect(text).toContain('Retry after') + const warning = yielded.find( + message => + message.type === 'system' && + message.subtype === 'informational' && + message.level === 'warning', + ) + expect(warning?.content).toContain('Automatic compaction is paused') + expect(warning?.content).toContain('retry after') +}) + +test('active auto-compact cooldown blocks message-count overflow before model call', async () => { + const messages = manySmallMessages(1001) + const nextRetryAtMs = Date.now() + 60_000 + const callModel = mock(() => { + throw new Error('model should not be called while autocompact cools down') + }) + const deps: QueryDeps = { + callModel: callModel as QueryDeps['callModel'], + microcompact: mock(async (input: Message[]) => ({ + messages: input, + })) as QueryDeps['microcompact'], + autocompact: mock(async () => ({ + wasCompacted: false, + consecutiveFailures: MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES, + nextRetryAtMs, + circuitBreakerActive: true, + circuitBreakerTripped: false, + })) as QueryDeps['autocompact'], + uuid: () => 'test-uuid', + } + + const { query } = await loadQuery() + const { yielded, terminal } = await drain( + query({ + messages, + systemPrompt: asSystemPrompt([]), + userContext: {}, + systemContext: {}, + canUseTool, + toolUseContext: toolUseContext(), + querySource: 'repl_main_thread', + deps, + }), + ) + + expect(callModel).not.toHaveBeenCalled() + expect(terminal.reason).toBe('blocking_limit') + const apiError = yielded.find( + (message): message is Message => + (message as { isApiErrorMessage?: boolean }).isApiErrorMessage === true, + ) + expect(apiError).toBeDefined() + const text = apiError!.message.content[0].text + expect(text).toContain('auto-compact safety threshold') + expect(text).toContain('Retry after') }) test('auto-compact cooldown tracking is carried into the next query call', async () => { diff --git a/src/query/providerMaxTokensCapRetry.test.ts b/src/query/providerMaxTokensCapRetry.test.ts index ac506b665..ceb26b87a 100644 --- a/src/query/providerMaxTokensCapRetry.test.ts +++ b/src/query/providerMaxTokensCapRetry.test.ts @@ -8,6 +8,7 @@ import type { QueryDeps } from './deps.js' import { createAssistantAPIErrorMessage, createAssistantMessage, + createCompactBoundaryMessage, createUserMessage, } from '../utils/messages.js' import { asSystemPrompt } from '../utils/systemPromptType.js' @@ -282,6 +283,109 @@ test('does not retry OpenRouter affordability errors handled by withRetry', asyn ).toBe(true) }) +test('OpenAI-compatible context overflow messages are tagged for recovery', () => { + const message = getAssistantMessageFromError( + APIError.generate( + 400, + undefined, + 'OpenAI API error 400: Bad Request [openai_category=context_overflow,host=api.z.ai] too many tokens', + new Headers(), + ), + 'zai/model', + ) + + expect(message.apiError).toBe('context_overflow') + expect(message.error).toBe('invalid_request') +}) + +test('compacts and retries once for context overflow errors', async () => { + let callCount = 0 + const seenRequestMessages: QueryParams['messages'][] = [] + const callModel: QueryDeps['callModel'] = async function* ({ messages }) { + seenRequestMessages.push(messages) + callCount += 1 + if (callCount === 1) { + yield createAssistantAPIErrorMessage({ + content: 'The conversation exceeded the provider context limit.', + apiError: 'context_overflow', + error: 'invalid_request', + }) + return + } + + yield createAssistantMessage({ content: 'ok after compact' }) + } + const params = makeParams(callModel) + const seenForceReasons: Array = [] + let autocompactCalls = 0 + params.deps = { + ...params.deps, + autocompact: async ( + _messages, + _toolUseContext, + _cacheSafeParams, + _querySource, + tracking, + ) => { + autocompactCalls += 1 + seenForceReasons.push(tracking?.forceReason) + if (autocompactCalls === 1) { + return { wasCompacted: false } + } + + return { + wasCompacted: true, + consecutiveFailures: 0, + compactionResult: { + boundaryMarker: createCompactBoundaryMessage('auto', 10_000), + summaryMessages: [ + createUserMessage({ content: 'compacted context summary' }), + ], + messagesToKeep: [], + attachments: [], + hookResults: [], + preCompactTokenCount: 10_000, + postCompactTokenCount: 500, + truePostCompactTokenCount: 500, + }, + } + }, + } as unknown as QueryDeps + + const messages = await collect(params) + + expect(callCount).toBe(2) + expect(seenForceReasons).toEqual([undefined, 'memory-pressure']) + const retryMessages = seenRequestMessages[1] ?? [] + expect( + retryMessages.some( + message => + message.type === 'user' && + message.isMeta === true && + typeof message.message.content === 'string' && + message.message.content.includes('exceeded the context window') && + message.message.content.includes('retrying this turn once'), + ), + ).toBe(true) + expect( + messages.some(message => message?.apiError === 'context_overflow'), + ).toBe(false) + expect( + messages.some( + message => + message?.type === 'system' && + message?.content?.includes('compacting conversation and retrying'), + ), + ).toBe(true) + expect( + messages.some( + message => + message?.type === 'assistant' && + message?.message?.content?.[0]?.text === 'ok after compact', + ), + ).toBe(true) +}) + test('does not retry malformed provider cap errors', async () => { const seenOverrides: Array = [] const callModel: QueryDeps['callModel'] = async function* ({ options }) { diff --git a/src/query/transitions.ts b/src/query/transitions.ts index 40e052305..f2ecf6159 100644 --- a/src/query/transitions.ts +++ b/src/query/transitions.ts @@ -20,6 +20,7 @@ export type Terminal = export type Continue = | { reason: 'collapse_drain_retry'; committed: number } | { reason: 'reactive_compact_retry' } + | { reason: 'context_overflow_compact_retry' } | { reason: 'provider_max_tokens_retry'; cap: number } | { reason: 'provider_fallback_retry' } | { reason: 'max_output_tokens_escalate' } diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index dbfe05290..548a1a534 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -2482,13 +2482,10 @@ async function* queryModel( max_tokens: maxOutputTokens, output_tokens: usage.output_tokens, }) - // Reuse the max_output_tokens recovery path — from the model's - // perspective, both mean "response was cut off, continue from - // where you left off." yield createAssistantAPIErrorMessage({ content: `${API_ERROR_MESSAGE_PREFIX}: The model has reached its context window limit.`, - apiError: 'max_output_tokens', - error: 'max_output_tokens', + apiError: 'context_overflow', + error: 'invalid_request', }) } break diff --git a/src/services/api/errors.ts b/src/services/api/errors.ts index 39d98ef59..6ad80c1c0 100644 --- a/src/services/api/errors.ts +++ b/src/services/api/errors.ts @@ -131,7 +131,9 @@ function mapOpenAICompatibilityFailureToAssistantMessage(options: { case 'context_overflow': return createAssistantAPIErrorMessage({ content: `The conversation exceeded the provider context limit. ${compactHint}`, + apiError: 'context_overflow', error: 'invalid_request', + errorDetails: stripOpenAICompatibilityMetadata(options.rawMessage), }) case 'tool_call_incompatible': @@ -1206,6 +1208,7 @@ export function getAssistantMessageFromError( : ' Press esc twice to go up a few messages, or run /compact to reduce context.' return createAssistantAPIErrorMessage({ content: `The conversation has grown too large for the API to process.${rewindInstruction} Alternatively, start a new session with /new.`, + apiError: 'context_overflow', error: 'invalid_request', errorDetails: `Context overflow (500): ${error.message}`, }) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 15e32677c..e26cf3fff 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -9190,6 +9190,58 @@ test('renders tool_reference blocks as text on the chat/completions path', async expect(content).toContain('mcp__example__memory_store') }) +test('preserves valid tool pairs after history pruning while dropping orphaned tool calls', async () => { + const { __test } = await import('./openaiShim.ts') + + const messages = __test.convertMessages( + [ + { role: 'user', content: 'compacted summary of previous work' }, + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call_pruned_without_result', + name: 'Read', + input: { file_path: 'old.ts' }, + }, + ], + }, + { role: 'user', content: 'continue with retained context' }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'Reading the current file.' }, + { + type: 'tool_use', + id: 'call_retained', + name: 'Read', + input: { file_path: 'current.ts' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_retained', + content: 'current contents', + }, + ], + }, + ], + undefined, + ) + + const toolCalls = messages.flatMap(message => message.tool_calls ?? []) + expect(toolCalls.map(toolCall => toolCall.id)).toEqual(['call_retained']) + + const toolMessages = messages.filter(message => message.role === 'tool') + expect(toolMessages).toHaveLength(1) + expect(toolMessages[0]?.tool_call_id).toBe('call_retained') +}) + function makeCodexSseResponse(responseData: Record): Response { const data = JSON.stringify(responseData) return makeSseResponse([`event: response.completed\ndata: ${data}\n\n`]) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 7958dc7c1..8ecbf30a0 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1117,34 +1117,40 @@ function convertMessages( if (role === 'user') { // Check for tool_result blocks in user messages if (Array.isArray(content)) { - const toolResults = content.filter( - (b: { type?: string }) => b.type === 'tool_result', - ) - const otherContent = content.filter( - (b: { type?: string }) => b.type !== 'tool_result', - ) + let otherContent: unknown[] | undefined // Emit tool results as tool messages, but ONLY if we have a matching tool_use ID. // Mistral/OpenAI strictly require tool messages to follow an assistant message with tool_calls. // If the user interrupted (ESC) and a synthetic tool_result was generated without a recorded tool_use, // emitting it here would cause a "role must alternate" or "unexpected role" error. - for (const tr of toolResults) { - const id = tr.tool_use_id ?? 'unknown' - if (knownToolCallIds.has(id)) { - result.push({ - role: 'tool', - tool_call_id: id, - content: convertToolResultContent(tr.content, tr.is_error), - }) + for (const block of content) { + const blockType = (block as { type?: string }).type + if (blockType === 'tool_result') { + const tr = block as { + tool_use_id?: string + content?: unknown + is_error?: boolean + } + const id = tr.tool_use_id ?? 'unknown' + if (knownToolCallIds.has(id)) { + result.push({ + role: 'tool', + tool_call_id: id, + content: convertToolResultContent(tr.content, tr.is_error), + }) + } else { + logForDebugging( + `Dropping orphan tool_result for ID: ${id} to prevent API error`, + ) + } } else { - logForDebugging( - `Dropping orphan tool_result for ID: ${id} to prevent API error`, - ) + otherContent ??= [] + otherContent.push(block) } } // Emit remaining user content - if (otherContent.length > 0) { + if (otherContent && otherContent.length > 0) { result.push({ role: 'user', content: convertContentBlocks(otherContent), @@ -1159,25 +1165,51 @@ function convertMessages( } else if (role === 'assistant') { // Check for tool_use blocks if (Array.isArray(content)) { - const toolUses = content.filter( - (b: { type?: string }) => b.type === 'tool_use', - ) - const thinkingBlock = content.find( - (b: { type?: string }) => - b.type === 'thinking' || - b.type === 'redacted_thinking', - ) - const textContent = content.filter( - (b: { type?: string }) => - b.type !== 'tool_use' && - b.type !== 'thinking' && - b.type !== 'redacted_thinking', - ) + let toolUses: Array<{ + id?: string + name?: string + input?: unknown + extra_content?: Record + signature?: string + }> | undefined + let thinkingBlock: + | { type?: string; thinking?: string; data?: string; signature?: string } + | undefined + let textContent: unknown[] | undefined + + for (const block of content) { + const blockType = (block as { type?: string }).type + if (blockType === 'tool_use') { + toolUses ??= [] + toolUses.push( + block as { + id?: string + name?: string + input?: unknown + extra_content?: Record + signature?: string + }, + ) + } else if ( + blockType === 'thinking' || + blockType === 'redacted_thinking' + ) { + thinkingBlock ??= block as { + type?: string + thinking?: string + data?: string + signature?: string + } + } else { + textContent ??= [] + textContent.push(block) + } + } const assistantMsg: OpenAIMessage = { role: 'assistant', content: (() => { - const c = convertContentBlocks(textContent) + const c = convertContentBlocks(textContent ?? []) return typeof c === 'string' ? c : Array.isArray(c) @@ -1199,82 +1231,70 @@ function convertMessages( // blocks carry it in `.data` (see token estimation and message-size // accounting). Read the right field per type so a real redacted block // with non-empty content is not silently dropped to "". - const block = thinkingBlock as - | { type?: string; thinking?: string; data?: string } - | undefined const thinkingText = - block?.type === 'redacted_thinking' - ? block?.data - : block?.thinking + thinkingBlock?.type === 'redacted_thinking' + ? thinkingBlock?.data + : thinkingBlock?.thinking if (typeof thinkingText === 'string' && thinkingText.trim().length > 0) { assistantMsg.reasoning_content = thinkingText } else if ( - toolUses.length > 0 && + (toolUses?.length ?? 0) > 0 && reasoningContentFallback === '' ) { assistantMsg.reasoning_content = '' } } - if (toolUses.length > 0) { - const mappedToolCalls = toolUses - .map( - (tu: { - id?: string - name?: string - input?: unknown - extra_content?: Record - signature?: string - }) => { - const id = tu.id ?? `call_${crypto.randomUUID().replace(/-/g, '')}` + if (toolUses && toolUses.length > 0) { + const mappedToolCalls: NonNullable = [] + for (const tu of toolUses) { + const id = tu.id ?? `call_${crypto.randomUUID().replace(/-/g, '')}` - // Only keep tool calls that have a corresponding result in the history, - // or if it's the last message (prefill scenario). - // Orphaned tool calls (e.g. from user interruption) cause 400 errors. - if (!toolResultIds.has(id) && !isLastInHistory) { - return null - } + // Only keep tool calls that have a corresponding result in the history, + // or if it's the last message (prefill scenario). + // Orphaned tool calls (e.g. from user interruption) cause 400 errors. + if (!toolResultIds.has(id) && !isLastInHistory) { + continue + } - knownToolCallIds.add(id) - const toolCall: NonNullable< - OpenAIMessage['tool_calls'] - >[number] = { - id, - type: 'function' as const, - function: { - name: tu.name ?? 'unknown', - arguments: - typeof tu.input === 'string' - ? tu.input - : JSON.stringify(tu.input ?? {}), - }, - } - - // Preserve existing extra_content if present - if (tu.extra_content) { - toolCall.extra_content = { ...tu.extra_content } - } - - // Gemini OpenAI-compatible endpoints require Google's - // thought_signature to be replayed with prior function-call - // parts. Preserve only real signatures received from the - // provider; synthetic placeholders are rejected by GMI. - if (preserveGeminiThoughtSignature) { - const signature = - tu.signature ?? - geminiThoughtSignatureFromExtraContent(tu.extra_content) ?? - (thinkingBlock as { signature?: string } | undefined)?.signature - - toolCall.extra_content = mergeGeminiThoughtSignature( - toolCall.extra_content, - signature, - ) - } - - return toolCall + knownToolCallIds.add(id) + const toolCall: NonNullable< + OpenAIMessage['tool_calls'] + >[number] = { + id, + type: 'function' as const, + function: { + name: tu.name ?? 'unknown', + arguments: + typeof tu.input === 'string' + ? tu.input + : JSON.stringify(tu.input ?? {}), }, - ) - .filter((tc): tc is NonNullable => tc !== null) + } + + // Preserve existing extra_content if present + if (tu.extra_content) { + toolCall.extra_content = { ...tu.extra_content } + } + + // Gemini OpenAI-compatible endpoints require Google's + // thought_signature to be replayed with prior function-call + // parts. Preserve only real signatures received from the + // provider; synthetic placeholders are rejected by GMI. + if (preserveGeminiThoughtSignature) { + const signature = + tu.signature ?? + geminiThoughtSignatureFromExtraContent(tu.extra_content) ?? + thinkingBlock?.signature + + toolCall.extra_content = mergeGeminiThoughtSignature( + toolCall.extra_content, + signature, + ) + } + + mappedToolCalls.push(toolCall) + } if (mappedToolCalls.length > 0) { assistantMsg.tool_calls = mappedToolCalls diff --git a/src/services/compact/autoCompact.test.ts b/src/services/compact/autoCompact.test.ts index 7362ff34c..c4dfe2103 100644 --- a/src/services/compact/autoCompact.test.ts +++ b/src/services/compact/autoCompact.test.ts @@ -575,6 +575,35 @@ describe('autoCompactIfNeeded circuit breaker', () => { expect(result.nextRetryAtMs).toBeGreaterThan(Date.now()) }) + test('forced compaction bypasses user-disable gates', async () => { + process.env.DISABLE_COMPACT = '1' + process.env.DISABLE_AUTO_COMPACT = '1' + const compactConversation = mock(async () => compactResult()) + const trySessionMemoryCompaction = mock(async () => null) + const { autoCompactIfNeeded } = await importAutoCompact({ + compactConversation, + trySessionMemoryCompaction, + }) + + const messages = underThresholdMessages() + const result = await autoCompactIfNeeded( + messages, + toolUseContext(), + cacheSafeParams(messages), + 'repl_main_thread', + { + compacted: false, + turnCounter: 0, + turnId: 'turn', + forceReason: 'message-count', + }, + ) + + expect(compactConversation).toHaveBeenCalledTimes(1) + expect(result.wasCompacted).toBe(true) + expect(result.consecutiveFailures).toBe(0) + }) + test('expired cooldown allows a half-open compaction attempt', async () => { const compactConversation = mock(async () => compactResult()) const trySessionMemoryCompaction = mock(async () => null) diff --git a/src/services/compact/autoCompact.ts b/src/services/compact/autoCompact.ts index eae0da2ea..e93e76092 100644 --- a/src/services/compact/autoCompact.ts +++ b/src/services/compact/autoCompact.ts @@ -68,7 +68,7 @@ export type AutoCompactTrackingState = { // threaded through query() callers rather than serialized into transcripts. nextRetryAtMs?: number lastFailureAtMs?: number - // When set, bypasses shouldAutoCompact() token threshold check. + // When set, bypasses shouldAutoCompact() token threshold and user-disable checks. // Used by memory pressure and message count guards to force compaction // even when token usage is below the normal autocompact threshold. forceReason?: 'memory-pressure' | 'message-count' @@ -268,10 +268,9 @@ export async function shouldAutoCompact( // pre-snip context, so tokenCountWithEstimation can't see the savings. // Subtract the rough-delta that snip already computed. snipTokensFreed = 0, - // When true, skip the token-threshold check but still run all guards - // (recursion, disabled, reactive-only, context-collapse). Used by - // forceReason to bypass only the token gate, not the safety guards. - skipTokenCheck = false, + // When set, skip user-disable and token-threshold checks but still run + // recursion/context-collapse guards. Used by runtime safety signals. + forceReason?: AutoCompactTrackingState['forceReason'], ): Promise { // Recursion guards. session_memory and compact are forked agents that // would deadlock. @@ -289,7 +288,7 @@ export async function shouldAutoCompact( } } - if (!isAutoCompactEnabled()) { + if (!forceReason && !isAutoCompactEnabled()) { return false } @@ -300,7 +299,10 @@ export async function shouldAutoCompact( // trySessionMemoryCompaction in the query loop — the /compact call site // still tries session memory first. Revisit if reactive-only graduates. if (feature('REACTIVE_COMPACT')) { - if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_raccoon', false)) { + if ( + !forceReason && + getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_raccoon', false) + ) { return false } } @@ -335,8 +337,10 @@ export async function shouldAutoCompact( } } - if (skipTokenCheck) { - logForDebugging('autocompact: skipping token threshold check (forced)') + if (forceReason) { + logForDebugging( + `autocompact: skipping token threshold check (forced by ${forceReason})`, + ) return true } @@ -372,25 +376,25 @@ export async function autoCompactIfNeeded( circuitBreakerActive?: boolean circuitBreakerTripped?: boolean }> { - if (isEnvTruthy(process.env.DISABLE_COMPACT)) { - return { wasCompacted: false } - } - const model = toolUseContext.options.mainLoopModel // Force compaction if a pressure/count signal set forceReason. - // Consume the flag so it only forces one compaction cycle. - // Pass skipTokenCheck to shouldAutoCompact so safety guards - // (disabled, reactive-only, context-collapse, recursion) still apply. + // Intentionally consume the caller-owned flag in place so the same tracking + // object cannot force multiple compaction cycles in one query loop pass. + // Forced safety compaction bypasses user-disable gates while preserving + // recursion/context-collapse guards. const forcedBy = tracking?.forceReason if (tracking?.forceReason) { tracking.forceReason = undefined } + if (!forcedBy && isEnvTruthy(process.env.DISABLE_COMPACT)) { + return { wasCompacted: false } + } const shouldCompact = await shouldAutoCompact( messages, model, querySource, snipTokensFreed, - !!forcedBy, + forcedBy, ) if (!shouldCompact) { diff --git a/src/utils/maxActiveMessages.test.ts b/src/utils/maxActiveMessages.test.ts new file mode 100644 index 000000000..0fa5e61f8 --- /dev/null +++ b/src/utils/maxActiveMessages.test.ts @@ -0,0 +1,68 @@ +import { afterEach, expect, test } from 'bun:test' + +import { + getMaxActiveMessagesHardCap, + isAboveMaxActiveMessagesLimit, + resolveMaxActiveMessagesLimit, + shouldCompactActiveMessageHistory, +} from './maxActiveMessages.js' + +const SAVED_ENV = { + OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP: + process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP, +} + +afterEach(() => { + for (const [key, value] of Object.entries(SAVED_ENV)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +}) + +test('invalid hard cap override falls back to the default safety cap', () => { + process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP = '100O' + + expect(getMaxActiveMessagesHardCap()).toBe(1000) + expect(isAboveMaxActiveMessagesLimit(1001)).toBe(true) +}) + +test('explicit zero hard cap disables only the hard cap', () => { + process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP = '0' + + expect(getMaxActiveMessagesHardCap()).toBe(0) + expect(isAboveMaxActiveMessagesLimit(1001)).toBe(false) + expect(resolveMaxActiveMessagesLimit('100', undefined)).toBe(100) + expect(resolveMaxActiveMessagesLimit('off', '5')).toBe(5) +}) + +test('configured and hard cap combine by choosing the tighter positive limit', () => { + process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP = '500' + + expect(resolveMaxActiveMessagesLimit('1000', undefined)).toBe(500) + expect(resolveMaxActiveMessagesLimit('100', undefined)).toBe(100) + expect(isAboveMaxActiveMessagesLimit(501)).toBe(true) + expect(isAboveMaxActiveMessagesLimit(500)).toBe(false) +}) + +test('teammate transcript compaction triggers on message count before token pressure', () => { + expect( + shouldCompactActiveMessageHistory({ + messageCount: 1001, + tokenCount: 10, + tokenThreshold: 100_000, + activeMessageLimit: 1000, + }), + ).toBe(true) + + expect( + shouldCompactActiveMessageHistory({ + messageCount: 1000, + tokenCount: 10, + tokenThreshold: 100_000, + activeMessageLimit: 1000, + }), + ).toBe(false) +}) diff --git a/src/utils/maxActiveMessages.ts b/src/utils/maxActiveMessages.ts new file mode 100644 index 000000000..4a683c9f5 --- /dev/null +++ b/src/utils/maxActiveMessages.ts @@ -0,0 +1,70 @@ +export const DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP = 1000 + +type MaxActiveMessagesEnv = Record + +export function parseMaxActiveMessagesLimit(value: string | undefined): number { + if (!value) { + return 0 + } + const trimmed = value.trim() + if (!/^(0|[1-9]\d*)$/.test(trimmed)) { + return 0 + } + const parsed = Number.parseInt(trimmed, 10) + return Number.isSafeInteger(parsed) ? parsed : 0 +} + +export function getMaxActiveMessagesHardCap( + env: MaxActiveMessagesEnv = process.env, +): number { + const hardCapOverride = + env.OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP + if (hardCapOverride === undefined) { + return DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP + } + const trimmed = hardCapOverride.trim() + if (trimmed === '0') { + return 0 + } + const parsed = parseMaxActiveMessagesLimit(trimmed) + return parsed > 0 ? parsed : DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP +} + +export function resolveMaxActiveMessagesLimit( + configSetting: string, + envSetting: string | undefined, +): number { + const configuredLimit = + configSetting !== 'off' + ? parseMaxActiveMessagesLimit(configSetting) + : parseMaxActiveMessagesLimit(envSetting) + const hardCap = getMaxActiveMessagesHardCap() + if (configuredLimit > 0 && hardCap > 0) { + return Math.min(configuredLimit, hardCap) + } + return configuredLimit > 0 ? configuredLimit : hardCap +} + +export function isAboveMaxActiveMessagesLimit( + messageCount: number, + limit = getMaxActiveMessagesHardCap(), +): boolean { + return limit > 0 && messageCount > limit +} + +export function shouldCompactActiveMessageHistory({ + messageCount, + tokenCount, + tokenThreshold, + activeMessageLimit = getMaxActiveMessagesHardCap(), +}: { + messageCount: number + tokenCount: number + tokenThreshold: number + activeMessageLimit?: number +}): boolean { + return ( + tokenCount > tokenThreshold || + isAboveMaxActiveMessagesLimit(messageCount, activeMessageLimit) + ) +} diff --git a/src/utils/swarm/inProcessRunner.ts b/src/utils/swarm/inProcessRunner.ts index c87e1ee2e..2e64dc1c3 100644 --- a/src/utils/swarm/inProcessRunner.ts +++ b/src/utils/swarm/inProcessRunner.ts @@ -69,6 +69,10 @@ import { type AgentContext, runWithAgentContext } from '../agentContext.js' import { count } from '../array.js' import { logForDebugging } from '../debug.js' import { cloneFileStateCache } from '../fileStateCache.js' +import { + getMaxActiveMessagesHardCap, + shouldCompactActiveMessageHistory, +} from '../maxActiveMessages.js' import { SUBAGENT_REJECT_MESSAGE, SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX, @@ -1100,12 +1104,20 @@ export async function runInProcessTeammate( // Check if compaction is needed before building context let contextMessages = allMessages const tokenCount = tokenCountWithEstimation(allMessages) + const activeMessageHardCap = getMaxActiveMessagesHardCap() + const tokenThreshold = getAutoCompactThreshold( + toolUseContext.options.mainLoopModel, + ) if ( - tokenCount > - getAutoCompactThreshold(toolUseContext.options.mainLoopModel) + shouldCompactActiveMessageHistory({ + messageCount: allMessages.length, + tokenCount, + tokenThreshold, + activeMessageLimit: activeMessageHardCap, + }) ) { logForDebugging( - `[inProcessRunner] ${identity.agentId} compacting history (${tokenCount} tokens)`, + `[inProcessRunner] ${identity.agentId} compacting history (${tokenCount} tokens, ${allMessages.length} messages)`, ) // Create an isolated copy of toolUseContext so that compaction // does not clear the main session's readFileState cache or @@ -1246,6 +1258,22 @@ export async function runInProcessTeammate( break } + if ( + message.type === 'system' && + 'subtype' in message && + message.subtype === 'compact_boundary' + ) { + allMessages.length = 0 + resetMicrocompactState() + if (teammateReplacementState) { + teammateReplacementState = createContentReplacementState() + } + updateTaskState( + taskId, + task => ({ ...task, messages: [] }), + setAppState, + ) + } iterationMessages.push(message) allMessages.push(message)