mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
+26
-5
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user