fix(memory): recover from autocompact overflow failures (#1858)

* fix(memory): recover from autocompact overflow failures

* fix(memory): address autocompact review findings

* fix(memory): close autocompact recovery gaps

* fix(memory): reduce OpenAI conversion pressure

* test(memory): add long-session guard smoke

* fix(memory): add runtime memory guard diagnostics

* fix(memory): surface autocompact failure diagnostics

* fix(memory): reuse hard-cap resolver in diagnostics

* fix(memory): avoid hard-cap diagnostic drift

* fix(memory): clarify hard-cap diagnostics
This commit is contained in:
JATMN
2026-07-06 08:16:01 +08:00
committed by GitHub
parent cd1cf3ca70
commit cd13a61537
16 changed files with 1217 additions and 143 deletions
+26 -5
View File
@@ -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.
+104
View File
@@ -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<string, string | undefined> = {}
@@ -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(() => ({
+99 -2
View File
@@ -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<void> {
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<void> {
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())
+188 -15
View File
@@ -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<ToolUseSummaryMessage | null> | 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,
+303
View File
@@ -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<AutoCompactTrackingState | undefined> = []
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<AutoCompactTrackingState | undefined> = []
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 () => {
+104
View File
@@ -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<string | undefined> = []
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<number | undefined> = []
const callModel: QueryDeps['callModel'] = async function* ({ options }) {
+1
View File
@@ -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' }
+2 -5
View File
@@ -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
+3
View File
@@ -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}`,
})
+52
View File
@@ -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<string, unknown>): Response {
const data = JSON.stringify(responseData)
return makeSseResponse([`event: response.completed\ndata: ${data}\n\n`])
+116 -96
View File
@@ -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<string, unknown>
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<string, unknown>
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<string, unknown>
signature?: string
}) => {
const id = tu.id ?? `call_${crypto.randomUUID().replace(/-/g, '')}`
if (toolUses && toolUses.length > 0) {
const mappedToolCalls: NonNullable<OpenAIMessage['tool_calls']> = []
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<typeof tc> => 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
+29
View File
@@ -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)
+21 -17
View File
@@ -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<boolean> {
// 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) {
+68
View File
@@ -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)
})
+70
View File
@@ -0,0 +1,70 @@
export const DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP = 1000
type MaxActiveMessagesEnv = Record<string, string | undefined>
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)
)
}
+31 -3
View File
@@ -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)