Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/QueryEngine.autoCompactCooldown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { test } from 'bun:test'
import { spawnSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
// Cold Windows checkouts can exceed Bun's default 5s while loading QueryEngine.
const FIXTURE_TIMEOUT_MS = 60_000
const TEST_TIMEOUT_MS = FIXTURE_TIMEOUT_MS + 5_000

test('SDK manual compact clears stale auto-compact cooldown tracking', () => {
const fixture = resolve(
repoRoot,
'src/test/fixtures/queryEngineManualCompactCooldown.fixture.ts',
)
// Keep QueryEngine module mocks out of Bun's shared test-process cache.
const result = spawnSync(process.execPath, [fixture], {
cwd: repoRoot,
encoding: 'utf8',
timeout: FIXTURE_TIMEOUT_MS,
env: {
...process.env,
FORCE_COLOR: '0',
},
})

if (result.error) {
throw result.error
}
if (result.status !== 0) {
throw new Error(
[
`Fixture exited with status ${result.status ?? 'unknown'}.`,
result.stdout.trim(),
result.stderr.trim(),
]
.filter(Boolean)
.join('\n\n'),
)
}
}, { timeout: TEST_TIMEOUT_MS })
15 changes: 14 additions & 1 deletion src/QueryEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { loadMemoryPrompt } from './memdir/memdir.js'
import { hasAutoMemPathOverride } from './memdir/paths.js'
import { query } from './query.js'
import { categorizeRetryableAPIError } from './services/api/errors.js'
import type { AutoCompactTrackingState } from './services/compact/autoCompact.js'
import type { MCPServerConnection } from './services/mcp/types.js'
import type { AppState } from './state/AppState.js'
import { type Tools, type ToolUseContext, toolMatchesName } from './Tool.js'
Expand Down Expand Up @@ -62,7 +63,11 @@ import {
import { headlessProfilerCheckpoint } from './utils/headlessProfiler.js'
import { registerStructuredOutputEnforcement } from './utils/hooks/hookHelpers.js'
import { getInMemoryErrors } from './utils/log.js'
import { countToolCalls, SYNTHETIC_MESSAGES } from './utils/messages.js'
import {
countToolCalls,
isCompactBoundaryMessage,
SYNTHETIC_MESSAGES,
} from './utils/messages.js'
import {
getMainLoopModel,
parseUserSpecifiedModel,
Expand Down Expand Up @@ -187,6 +192,7 @@ export class QueryEngine {
private totalUsage: NonNullableUsage
private hasHandledOrphanedPermission = false
private readFileState: FileStateCache
private autoCompactTracking: AutoCompactTrackingState | undefined
// Turn-scoped skill discovery tracking (feeds was_discovered on
// tengu_skill_tool_invocation). Must persist across the two
// processUserInputContext rebuilds inside submitMessage, but is cleared
Expand Down Expand Up @@ -427,6 +433,9 @@ export class QueryEngine {

// Push new messages, including user input and any attachments
this.mutableMessages.push(...messagesFromUserInput)
if (messagesFromUserInput.some(isCompactBoundaryMessage)) {
this.autoCompactTracking = undefined
}

// Update params to reflect updates from processing /slash commands
const messages = [...this.mutableMessages]
Expand Down Expand Up @@ -681,6 +690,10 @@ export class QueryEngine {
querySource: 'sdk',
maxTurns,
taskBudget,
autoCompactTracking: this.autoCompactTracking,
onAutoCompactTrackingChange: tracking => {
this.autoCompactTracking = tracking
},
})) {
// Record assistant, user, and compact boundary messages
if (
Expand Down
98 changes: 79 additions & 19 deletions src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { FallbackTriggeredError } from './services/api/withRetry.js'
import {
calculateTokenWarningState,
isAutoCompactEnabled,
MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES,
type AutoCompactTrackingState,
} from './services/compact/autoCompact.js'
import { buildPostCompactMessages } from './services/compact/compact.js'
Expand Down Expand Up @@ -169,6 +170,15 @@ function* yieldMissingToolResultBlocks(
const MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3
const MAX_CONTINUATION_NUDGES = 3

function formatAutoCompactRetryDelay(delayMs: number): string {
const totalSeconds = Math.max(1, Math.ceil(delayMs / 1000))
if (totalSeconds < 60) {
return `${totalSeconds} second${totalSeconds === 1 ? '' : 's'}`
}
const totalMinutes = Math.ceil(totalSeconds / 60)
return `${totalMinutes} minute${totalMinutes === 1 ? '' : 's'}`
}

/**
* 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
Expand Down Expand Up @@ -196,6 +206,10 @@ export type QueryParams = {
maxOutputTokensOverride?: number
maxTurns?: number
skipCacheWrite?: boolean
autoCompactTracking?: AutoCompactTrackingState
onAutoCompactTrackingChange?: (
tracking: AutoCompactTrackingState | undefined,
) => void
// API task_budget (output_config.task_budget, beta task-budgets-2026-03-13).
// Distinct from the tokenBudget +500k auto-continue feature. `total` is the
// budget for the whole agentic turn; `remaining` is computed per iteration
Expand Down Expand Up @@ -288,7 +302,7 @@ async function* queryLoop(
messages: params.messages,
toolUseContext: params.toolUseContext,
maxOutputTokensOverride: params.maxOutputTokensOverride,
autoCompactTracking: undefined,
autoCompactTracking: params.autoCompactTracking,
stopHookActive: undefined,
maxOutputTokensRecoveryCount: 0,
hasAttemptedReactiveCompact: false,
Expand All @@ -299,6 +313,12 @@ async function* queryLoop(
}
const budgetTracker = feature('TOKEN_BUDGET') ? createBudgetTracker() : null

const updateAutoCompactTracking = (
tracking: AutoCompactTrackingState | undefined,
) => {
params.onAutoCompactTrackingChange?.(tracking)
}

// task_budget.remaining tracking across compaction boundaries. Undefined
// until first compact fires — while context is uncompacted the server can
// see the full history and handles the countdown from {total} itself (see
Expand Down Expand Up @@ -507,7 +527,14 @@ async function* queryLoop(
)

queryCheckpoint('query_autocompact_start')
const { compactionResult, consecutiveFailures } = await deps.autocompact(
const {
compactionResult,
consecutiveFailures,
nextRetryAtMs,
lastFailureAtMs,
circuitBreakerActive,
circuitBreakerTripped,
} = await deps.autocompact(
messagesForQuery,
toolUseContext,
{
Expand Down Expand Up @@ -580,6 +607,7 @@ async function* queryLoop(
turnCounter: 0,
consecutiveFailures: 0,
}
updateAutoCompactTracking(tracking)

const postCompactMessages = buildPostCompactMessages(compactionResult)

Expand All @@ -589,13 +617,31 @@ async function* queryLoop(

// Continue on with the current query call using the post compact messages
messagesForQuery = postCompactMessages
} else if (consecutiveFailures !== undefined) {
// Autocompact failed — propagate failure count so the circuit breaker
// can stop retrying on the next iteration.
tracking = {
} else if (
consecutiveFailures !== undefined ||
nextRetryAtMs !== undefined ||
lastFailureAtMs !== undefined ||
circuitBreakerActive !== undefined ||
circuitBreakerTripped !== undefined
) {
// Autocompact returned breaker metadata. Thread it through the loop so
// cooldown can skip retry storms, expire, and then half-open retry.
const nextTracking: AutoCompactTrackingState = {
...(tracking ?? { compacted: false, turnId: '', turnCounter: 0 }),
consecutiveFailures,
}
if (consecutiveFailures !== undefined) {
nextTracking.consecutiveFailures = consecutiveFailures
}
if (nextRetryAtMs !== undefined) {
nextTracking.nextRetryAtMs = nextRetryAtMs
} else {
delete nextTracking.nextRetryAtMs
}
if (lastFailureAtMs !== undefined) {
nextTracking.lastFailureAtMs = lastFailureAtMs
}
tracking = nextTracking
updateAutoCompactTracking(tracking)
}

//TODO: no need to set toolUseContext.messages during set-up since it is updated here
Expand Down Expand Up @@ -701,16 +747,15 @@ async function* queryLoop(
}
}

// Safety net: when auto-compact's circuit breaker has tripped (3+
// consecutive failures), the normal blocking check above is gated on
// reactiveCompact. If reactiveCompact is also enabled but ALSO fails
// (or is disabled), the oversized context goes straight to the API and
// gets a 500. This check catches that gap — if compaction is exhausted
// and context is still over the autocompact threshold, block immediately
// with a clear message instead of burning an API call that will 500.
// 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
// of burning an oversized API call.
if (
tracking?.consecutiveFailures !== undefined &&
tracking.consecutiveFailures >= 3 &&
tracking.consecutiveFailures >=
MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES &&
isAutoCompactEnabled()
) {
const model = toolUseContext.options.mainLoopModel
Expand All @@ -720,10 +765,20 @@ async function* queryLoop(
model,
)
if (isAboveAutoCompactThreshold) {
const nowMs = Date.now()
const retryDelayMs =
tracking.nextRetryAtMs !== undefined
? tracking.nextRetryAtMs - nowMs
: undefined
const content =
retryDelayMs !== undefined && retryDelayMs > 0
? 'The conversation is over the auto-compact 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. ' +
'OpenClaude stopped before sending another oversized request. Run /compact, undo recent large tool output, or start a new session with /new.'
yield createAssistantAPIErrorMessage({
content:
'The conversation has exceeded the context limit and automatic compaction has failed. ' +
'Press esc twice to go up a few messages and try again, or start a new session with /new.',
content,
error: 'invalid_request',
})
return { reason: 'blocking_limit' }
Expand Down Expand Up @@ -1231,6 +1286,7 @@ async function* queryLoop(
for (const msg of postCompactMessages) {
yield msg
}
updateAutoCompactTracking(undefined)
const next: State = {
messages: postCompactMessages,
toolUseContext,
Expand Down Expand Up @@ -1720,7 +1776,11 @@ async function* queryLoop(
}

if (tracking?.compacted) {
tracking.turnCounter++
tracking = {
...tracking,
turnCounter: tracking.turnCounter + 1,
}
updateAutoCompactTracking(tracking)
logEvent('tengu_post_autocompact_turn', {
turnId:
tracking.turnId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
Expand Down
Loading