mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
* feat(context-collapse): implement context collapse for proactive context management
* feat(context-collapse): add turn-boundary helpers for span selection
* feat(context-collapse): deterministic turn-anchored span selection
* feat(context-collapse): code-computed span risk score
* feat(context-collapse): ctx-agent summarization instruction
* feat(context-collapse): implement ctx-agent span summarization spawn
* fix(context-collapse): make runtime activation opt-in (CLAUDE_CONTEXT_COLLAPSE)
* fix(context-collapse): address review feedback on restore state and test rigor
- restoreContextCollapseState now resets armed/lastSpawnTokens up front so a
snapshot-less restore cannot carry stale spawn state across sessions.
- projectView reuses a stable timestamp from the replaced span instead of
new Date(), keeping the read-side projection deterministic.
- Strengthen the disabled-state and turn-boundary assertions, drop an internal
renderToolUseMessage assertion, and isolate the operations/persist/spawn tests
from shared module and CLAUDE_CONTEXT_COLLAPSE env state.
* test(context-collapse): re-init enablement in persist.test hooks
resetContextCollapse() does not re-read CLAUDE_CONTEXT_COLLAPSE, so the
afterEach env delete left enabled=true in module state, leaking to the
next test file. Call initContextCollapse() in both hooks so module
enablement stays synced to the env var.
* test(context-collapse): stop spawnCtxAgent module stubs leaking across files
spawnCtxAgent.test.ts stubs shared modules (tokens, forkedAgent, messages,
analytics, log, spanSelection) via mock.module in beforeEach. bun's
mock.restore() does not undo mock.module, so the tokens stub (() => 100000)
bled into autoCompact/microCompact/runAgent tests run later in the full serial
suite, making them see every conversation as over-threshold (4 spurious
failures in test:full, all green in isolation).
Restore each stub to its real implementation in afterEach. The reals are
snapshotted into plain objects up front because 'import * as' yields a live
namespace that mock.module mutates in place, so holding the namespace would
restore the stub. autoCompact.js is deliberately not restored here since
autoCompact.test.ts re-imports it fresh via a cache-busting nonce.
Also reset+reinit the collapse module in afterEach so enabled state stays
synced to the now-unset env var.
* test(context-collapse): also restore autoCompact stub from spawnCtxAgent
The getEffectiveContextWindowSize stub on ../compact/autoCompact.js was the one
module the previous commit left unrestored, on the assumption that restoring it
would clash with autoCompact.test.ts's nonce re-import. It doesn't: the nonce
import uses a different specifier, and the snapshot restore is keyed by the
plain specifier. compressToolHistory imports getEffectiveContextWindowSize and
sizes tool-history truncation from it, so the leaked 20000-token window made it
fully omit tool results ('chars omitted') instead of mid-truncating
('[…truncated') for large-context models, failing the openaiShim compression
tests in the full serial suite. Restore all seven mocked modules.
* fix(context-collapse): re-arm after reset and gate ctx_inspect on opt-in
resetContextCollapse() left armed=false while enabled stayed true, so the
first /compact, main-thread compaction cleanup, or rewind permanently
disabled collapse for the rest of an opted-in session. Reset now mirrors
restoreContextCollapseState and sets armed=enabled.
CtxInspectTool.isEnabled() returned true unconditionally, advertising
ctx_inspect to the model in every default session even when the runtime
opt-in was off. It now returns isContextCollapseEnabled(). The opt-in is
also exposed as the contextCollapseEnabled global config key, so it is
reachable through /config instead of only the CLAUDE_CONTEXT_COLLAPSE env
var.
* refactor(context-collapse): drop no-op ternary in drainStaged persist call
The (stagedQueue.length > 0 ? 0 : 0) subtrahend always evaluated to 0, so
this is just persistCommits(processed.length).
* fix(context-collapse): persist commits before advancing the snapshot
drainStaged removed processed spans from the staged queue and then fired
persistCommits and persistSnapshot in parallel. If the snapshot write (which
no longer lists those spans as staged) landed while the commit write failed
or the process died between them, restore would find the spans neither staged
nor committed and the collapse would disappear on resume. Chain the snapshot
write after the commit write so the commit log is durable first.
* fix(context-collapse): project committed collapses on the query path, fix opt-in reach
Three issues from review:
- Committed collapses were never re-applied to the model input. The query path
calls applyCollapsesIfNeeded but only drained staged spans; projectView (which
replays the commit log) ran only in /context. Since messagesForQuery is rebuilt
from full REPL history each turn and the commit log is repopulated on resume,
the archived spans returned to the model on the next turn, undoing the collapse.
applyCollapsesIfNeeded now runs projectView first (idempotent). Adds a
regression that a committed collapse changes the next query input.
- Cache-safe params were saved only for exact repl_main_thread/sdk sources, but
the REPL tags non-default output styles as repl_main_thread:outputStyle:*, so
those sessions left the ctx-agent without params (empty spawns). Matches
repl_main_thread:* now, via a small tested helper.
- contextCollapseEnabled had no settings control. Adds a /config toggle that
refreshes runtime state (re-runs initContextCollapse) so it applies without a
restart.
* fix(context-collapse): clear already-committed staged spans; harden config toggle
After projecting committed collapses before draining, a span present in both the
commit log and the staged snapshot (a restore whose snapshot predates the
matching commit write) could not be drained — projectView had already removed
its messages — so it lingered in stagedQueue and distorted spawn/overflow
checks. drainStaged now drops staged spans that are already committed and syncs
the snapshot. Adds a regression covering the committed+staged overlap restore.
Also wraps the /config context-collapse refresh in try/catch so a failed
require/init can't crash the settings UI, and lists the toggle in the
save-and-close change summary like the neighboring compaction settings.
* fix(context-collapse): re-sync runtime state on config cancel
The context-collapse toggle's onChange refreshes the module-level
enabled/armed cache via initContextCollapse(). The revert path restored
the config key on disk but left that cache untouched, so enabling the
toggle and then pressing Escape kept collapse active for the rest of the
session. Re-init context collapse after the global config snapshot is
restored so cancel fully reverts runtime state.
* fix(context-collapse): keep collapsed summaries visible to the model
projectView and drainStaged replaced an archived span with a system
informational placeholder, but normalizeMessagesForAPI filters out every
system message that is not a local command. So once a collapse committed,
the next model request lost both the archived messages and the
<collapsed> summary meant to stand in for them, defeating the feature.
Mark the placeholder with isCollapseSummary and let it take the same
model-input path as local-command system messages (converted to a user
message), so the summary survives normalization. Added a regression that
runs the projected view through normalizeMessagesForAPI and asserts the
summary is still present.
* fix(context-collapse): avoid competing snapshot write after drain
After an immediate post-spawn drain, drainStaged(messages, true) starts
its own persistCommits().then(persistSnapshot) chain to guarantee commit
durability before the snapshot stops listing the staged spans. The
unconditional await persistSnapshot() that followed could win that race
and persist a snapshot with no staged spans before the commits landed,
reopening the crash window that drops collapses on restore. Only persist
directly when nothing was drained.
* fix(context-collapse): fall back, keep summaries non-snippable, gate /context
Three review findings:
- Suppress autocompact and the blocking preempt only when collapse holds a
real committed/staged reduction, not on mere enablement. Adds
hasActiveReduction(); a first over-threshold turn where spawnCtxAgent cannot
produce a span (getLastCacheSafeParams() still null) now falls back to
autocompact/blocking instead of sending an oversized transcript.
- Preserve isMeta when converting a collapse-summary placeholder to a user
message in normalizeMessagesForAPI, so the HISTORY_SNIP sweep cannot tag the
only replacement for an archived span as snippable.
- Gate the two /context projectView calls on isContextCollapseEnabled(), so a
disabled session does not under-report token usage from a lingering commit
log while the API receives the full transcript.
Adds regressions for hasActiveReduction and for the summary surviving
normalization as a non-snippable meta message.
* fix(context-collapse): scope collapse to the main thread that owns the store
The collapse store (commitLog/stagedQueue) is module-level and shared by
in-process subagents (agent:*) and the ctx-agent (marble_origami), which
run in the same process but do not own the main transcript.
applyCollapsesIfNeeded only skipped marble_origami, so a subagent could
stage or commit a span, flip the global hasActiveReduction(), and make
the next main-thread turn suppress autocompact and the blocking
prompt-too-long preempt while projectView() no-ops against the main
messages, sending an oversized transcript to the API.
Add isMainThreadSource() and gate both application (applyCollapsesIfNeeded,
isWithheldPromptTooLong, recoverFromOverflow) and fallback suppression
(autoCompact shouldAutoCompact, query collapseOwnsIt) to the owning
thread. Subagents now autocompact and preempt their own oversized turns
normally and never mutate the shared store.
Also adds the staged-only hasActiveReduction regression CodeRabbit
requested.
* fix(context-collapse): persist archived count so resumed stats stay accurate
restoreContextCollapseState rebuilt each commit with an empty archived
list, and getStats summed that list, so after a resume /context, the
context visualization, the token warning, and ctx_inspect reported
'N spans summarized (0 messages)' even though projectView was actively
removing the archived spans. The persisted-entry docstring claimed
projectView lazily refills the archive, but it only splices by boundary
uuid and never does.
The archived messages are never read back (only their count fed
getStats), so replace the per-commit Message[] with a persisted
archivedCount. It is written with each commit and restored on resume;
pre-field sessions restore as 0. getStats now reports the same figure
live and after resume.
* fix(context-collapse): keep collapse summary non-snippable across user merge
Preserving isMeta on the system->user conversion was not enough: when the
collapsed span ends right before the next user turn, normalizeMessagesForAPI
merges the summary into that real user message. Under HISTORY_SNIP
mergeUserMessages clears isMeta whenever an operand is real user content and
keeps the real turn's uuid, so the combined block — which carries the only
<collapsed> replacement for the archived span — got a snip id and the model
could queue it for removal.
Carry an isCollapseSummary marker onto the converted user message and through
mergeUserMessages (either operand), strip any snip id already baked into the
real turn when the merge absorbs a summary, and skip such blocks in
appendMessageTagToUserMessage. The merged block stays non-snippable
regardless of merge direction or isMeta being cleared.
* fix(context-collapse): preserve collapse marker on split, drop empty snip blocks
normalizeMessages split path now forwards isCollapseSummary so an array-backed
collapse summary keeps its non-snippable marker across API normalization.
stripSnipTagsFromContent drops a text block whose only content was the snip
marker, so the merge recovery path no longer emits an empty text block.
559 lines
20 KiB
TypeScript
559 lines
20 KiB
TypeScript
import { feature } from 'bun:bundle'
|
|
import { markPostCompaction } from 'src/bootstrap/state.js'
|
|
import { getSdkBetas } from '../../bootstrap/state.js'
|
|
import type { QuerySource } from '../../constants/querySource.js'
|
|
import type { ToolUseContext } from '../../Tool.js'
|
|
import type { Message } from '../../types/message.js'
|
|
import { getGlobalConfig } from '../../utils/config.js'
|
|
import { getContextWindowForModel } from '../../utils/context.js'
|
|
import { logForDebugging } from '../../utils/debug.js'
|
|
import { isEnvTruthy } from '../../utils/envUtils.js'
|
|
import { hasExactErrorMessage } from '../../utils/errors.js'
|
|
import type { CacheSafeParams } from '../../utils/forkedAgent.js'
|
|
import { logError } from '../../utils/log.js'
|
|
import { tokenCountWithEstimation } from '../../utils/tokens.js'
|
|
import { partitionContext } from '../../utils/contextPartitioning.js'
|
|
import { pruneByRelevance } from '../../utils/relevancePruning.js'
|
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'
|
|
import { getMaxOutputTokensForModel } from '../api/claude.js'
|
|
import { notifyCompaction } from '../api/promptCacheBreakDetection.js'
|
|
import { setLastSummarizedMessageId } from '../SessionMemory/sessionMemoryUtils.js'
|
|
import {
|
|
type CompactionResult,
|
|
compactConversation,
|
|
ERROR_MESSAGE_USER_ABORT,
|
|
type RecompactionInfo,
|
|
} from './compact.js'
|
|
import { runPostCompactCleanup } from './postCompactCleanup.js'
|
|
import { trySessionMemoryCompaction } from './sessionMemoryCompact.js'
|
|
|
|
// Reserve this many tokens for output during compaction
|
|
// Based on p99.99 of compact summary output being 17,387 tokens.
|
|
const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000
|
|
|
|
// Returns the context window size minus the max output tokens for the model
|
|
export function getEffectiveContextWindowSize(model: string): number {
|
|
const reservedTokensForSummary = Math.min(
|
|
getMaxOutputTokensForModel(model),
|
|
MAX_OUTPUT_TOKENS_FOR_SUMMARY,
|
|
)
|
|
let contextWindow = getContextWindowForModel(model, getSdkBetas())
|
|
|
|
const autoCompactWindow = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
|
|
if (autoCompactWindow) {
|
|
const parsed = parseInt(autoCompactWindow, 10)
|
|
if (!isNaN(parsed) && parsed > 0) {
|
|
contextWindow = Math.min(contextWindow, parsed)
|
|
}
|
|
}
|
|
|
|
// Floor: effective context must be at least the summary reservation plus a
|
|
// usable buffer. If it goes lower, the auto-compact threshold becomes
|
|
// negative and fires on every message (issue #635).
|
|
const autocompactBuffer = 13_000 // must match AUTOCOMPACT_BUFFER_TOKENS
|
|
const effectiveContext = contextWindow - reservedTokensForSummary
|
|
return Math.max(effectiveContext, reservedTokensForSummary + autocompactBuffer)
|
|
}
|
|
|
|
export type AutoCompactTrackingState = {
|
|
compacted: boolean
|
|
turnCounter: number
|
|
// Unique ID per turn
|
|
turnId: string
|
|
// Consecutive autocompact failures. Reset on success.
|
|
// Used by the cooldown circuit breaker to avoid retry storms when the
|
|
// context is irrecoverably over the limit (e.g., prompt_too_long).
|
|
consecutiveFailures?: number
|
|
// Process-local retry timestamp for the cooldown breaker. This state is
|
|
// threaded through query() callers rather than serialized into transcripts.
|
|
nextRetryAtMs?: number
|
|
lastFailureAtMs?: number
|
|
// When set, bypasses shouldAutoCompact() token threshold check.
|
|
// 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'
|
|
}
|
|
|
|
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000
|
|
export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000
|
|
export const ERROR_THRESHOLD_BUFFER_TOKENS = 20_000
|
|
export const MANUAL_COMPACT_BUFFER_TOKENS = 3_000
|
|
|
|
export const AUTOCOMPACT_FAILURE_COOLDOWN_MS = 5 * 60 * 1000
|
|
|
|
// Pause autocompact after this many consecutive failures.
|
|
// BQ 2026-03-10: 1,279 sessions had 50+ consecutive failures (up to 3,272)
|
|
// in a single session, wasting ~250K API calls/day globally.
|
|
export const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3
|
|
|
|
export function getAutoCompactFailureCooldownMs(): number {
|
|
const override = process.env.OPENCLAUDE_AUTOCOMPACT_FAILURE_COOLDOWN_MS
|
|
if (override) {
|
|
const trimmed = override.trim()
|
|
const parsed = Number(trimmed)
|
|
if (/^[1-9]\d*$/.test(trimmed) && Number.isSafeInteger(parsed)) {
|
|
return parsed
|
|
}
|
|
}
|
|
return AUTOCOMPACT_FAILURE_COOLDOWN_MS
|
|
}
|
|
|
|
export function resolveAutoCompactCircuitBreakerState(args: {
|
|
tracking?: Pick<
|
|
AutoCompactTrackingState,
|
|
'consecutiveFailures' | 'nextRetryAtMs' | 'lastFailureAtMs'
|
|
>
|
|
nowMs: number
|
|
cooldownMs: number
|
|
}):
|
|
| {
|
|
action: 'allow'
|
|
effectiveConsecutiveFailures: number
|
|
wasHalfOpen: boolean
|
|
}
|
|
| {
|
|
action: 'skip'
|
|
consecutiveFailures: number
|
|
nextRetryAtMs: number
|
|
circuitBreakerActive: true
|
|
} {
|
|
const { tracking, nowMs, cooldownMs } = args
|
|
const consecutiveFailures = Math.max(0, tracking?.consecutiveFailures ?? 0)
|
|
if (consecutiveFailures < MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES) {
|
|
return {
|
|
action: 'allow',
|
|
effectiveConsecutiveFailures: consecutiveFailures,
|
|
wasHalfOpen: false,
|
|
}
|
|
}
|
|
|
|
let nextRetryAtMs = tracking?.nextRetryAtMs
|
|
if (
|
|
(typeof nextRetryAtMs !== 'number' ||
|
|
!Number.isFinite(nextRetryAtMs)) &&
|
|
typeof tracking?.lastFailureAtMs === 'number' &&
|
|
Number.isFinite(tracking.lastFailureAtMs) &&
|
|
Number.isFinite(cooldownMs)
|
|
) {
|
|
nextRetryAtMs = tracking.lastFailureAtMs + cooldownMs
|
|
}
|
|
if (
|
|
typeof nextRetryAtMs === 'number' &&
|
|
Number.isFinite(nextRetryAtMs) &&
|
|
nowMs < nextRetryAtMs
|
|
) {
|
|
return {
|
|
action: 'skip',
|
|
consecutiveFailures,
|
|
nextRetryAtMs,
|
|
circuitBreakerActive: true,
|
|
}
|
|
}
|
|
|
|
return {
|
|
action: 'allow',
|
|
effectiveConsecutiveFailures:
|
|
MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES - 1,
|
|
wasHalfOpen: true,
|
|
}
|
|
}
|
|
|
|
export function getAutoCompactThreshold(model: string): number {
|
|
const effectiveContextWindow = getEffectiveContextWindowSize(model)
|
|
|
|
const autocompactThreshold =
|
|
effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS
|
|
|
|
// Override for easier testing of autocompact
|
|
const envPercent = process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE
|
|
if (envPercent) {
|
|
const parsed = parseFloat(envPercent)
|
|
if (!isNaN(parsed) && parsed > 0 && parsed <= 100) {
|
|
const percentageThreshold = Math.floor(
|
|
effectiveContextWindow * (parsed / 100),
|
|
)
|
|
return Math.min(percentageThreshold, autocompactThreshold)
|
|
}
|
|
}
|
|
|
|
return autocompactThreshold
|
|
}
|
|
|
|
export function calculateTokenWarningState(
|
|
tokenUsage: number,
|
|
model: string,
|
|
): {
|
|
percentLeft: number
|
|
isAboveWarningThreshold: boolean
|
|
isAboveErrorThreshold: boolean
|
|
isAboveAutoCompactThreshold: boolean
|
|
isAtBlockingLimit: boolean
|
|
} {
|
|
const autoCompactThreshold = getAutoCompactThreshold(model)
|
|
const threshold = isAutoCompactEnabled()
|
|
? autoCompactThreshold
|
|
: getEffectiveContextWindowSize(model)
|
|
|
|
// Use the raw context window (without output reservation) for the percentage
|
|
// display, so users see remaining context relative to the model's full capacity.
|
|
// The threshold (which subtracts buffer) should only affect when we warn/compact,
|
|
// not what percentage we display.
|
|
const rawContextWindow = getContextWindowForModel(model, getSdkBetas())
|
|
const percentLeft = Math.max(
|
|
0,
|
|
Math.round(((rawContextWindow - tokenUsage) / rawContextWindow) * 100),
|
|
)
|
|
|
|
const warningThreshold = threshold - WARNING_THRESHOLD_BUFFER_TOKENS
|
|
const errorThreshold = threshold - ERROR_THRESHOLD_BUFFER_TOKENS
|
|
|
|
const isAboveWarningThreshold = tokenUsage >= warningThreshold
|
|
const isAboveErrorThreshold = tokenUsage >= errorThreshold
|
|
|
|
const isAboveAutoCompactThreshold =
|
|
isAutoCompactEnabled() && tokenUsage >= autoCompactThreshold
|
|
|
|
const actualContextWindow = getEffectiveContextWindowSize(model)
|
|
const defaultBlockingLimit =
|
|
actualContextWindow - MANUAL_COMPACT_BUFFER_TOKENS
|
|
|
|
// Allow override for testing
|
|
const blockingLimitOverride = process.env.CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE
|
|
const parsedOverride = blockingLimitOverride
|
|
? parseInt(blockingLimitOverride, 10)
|
|
: NaN
|
|
const blockingLimit =
|
|
!isNaN(parsedOverride) && parsedOverride > 0
|
|
? parsedOverride
|
|
: defaultBlockingLimit
|
|
|
|
const isAtBlockingLimit = tokenUsage >= blockingLimit
|
|
|
|
return {
|
|
percentLeft,
|
|
isAboveWarningThreshold,
|
|
isAboveErrorThreshold,
|
|
isAboveAutoCompactThreshold,
|
|
isAtBlockingLimit,
|
|
}
|
|
}
|
|
|
|
export function isAutoCompactEnabled(): boolean {
|
|
if (isEnvTruthy(process.env.DISABLE_COMPACT)) {
|
|
return false
|
|
}
|
|
// Allow disabling just auto-compact (keeps manual /compact working)
|
|
if (isEnvTruthy(process.env.DISABLE_AUTO_COMPACT)) {
|
|
return false
|
|
}
|
|
// Check if user has disabled auto-compact in their settings
|
|
const userConfig = getGlobalConfig()
|
|
return userConfig.autoCompactEnabled
|
|
}
|
|
|
|
export async function shouldAutoCompact(
|
|
messages: Message[],
|
|
model: string,
|
|
querySource?: QuerySource,
|
|
// Snip removes messages but the surviving assistant's usage still reflects
|
|
// 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,
|
|
): Promise<boolean> {
|
|
// Recursion guards. session_memory and compact are forked agents that
|
|
// would deadlock.
|
|
if (querySource === 'session_memory' || querySource === 'compact') {
|
|
return false
|
|
}
|
|
// marble_origami is the ctx-agent — if ITS context blows up and
|
|
// autocompact fires, runPostCompactCleanup calls resetContextCollapse()
|
|
// which destroys the MAIN thread's committed log (module-level state
|
|
// shared across forks). Inside feature() so the string DCEs from
|
|
// external builds (it's in excluded-strings.txt).
|
|
if (feature('CONTEXT_COLLAPSE')) {
|
|
if (querySource === 'marble_origami') {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if (!isAutoCompactEnabled()) {
|
|
return false
|
|
}
|
|
|
|
// Reactive-only mode: suppress proactive autocompact, let reactive compact
|
|
// catch the API's prompt-too-long. feature() wrapper keeps the flag string
|
|
// out of external builds (REACTIVE_COMPACT is internal-only).
|
|
// Note: returning false here also means autoCompactIfNeeded never reaches
|
|
// 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)) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Context-collapse mode: same suppression. Collapse IS the context
|
|
// management system when it's on — the 90% commit / 95% blocking-spawn
|
|
// flow owns the headroom problem. Autocompact firing at effective-13k
|
|
// (~93% of effective) sits right between collapse's commit-start (90%)
|
|
// and blocking (95%), so it would race collapse and usually win, nuking
|
|
// granular context that collapse was about to save. Gating here rather
|
|
// than in isAutoCompactEnabled() keeps reactiveCompact alive as the 413
|
|
// fallback (it consults isAutoCompactEnabled directly) and leaves
|
|
// sessionMemory + manual /compact working.
|
|
//
|
|
// hasActiveReduction() folds in the enablement check (so the
|
|
// CLAUDE_CONTEXT_COLLAPSE env override is honored here too) but also
|
|
// requires collapse to actually hold a committed/staged reduction.
|
|
// require() inside the block breaks the init-time cycle (this file exports
|
|
// getEffectiveContextWindowSize which collapse's index imports).
|
|
if (feature('CONTEXT_COLLAPSE')) {
|
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
const { hasActiveReduction, isMainThreadSource } =
|
|
require('../contextCollapse/index.js') as typeof import('../contextCollapse/index.js')
|
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
// Suppress only when collapse actually holds the headroom (a committed or
|
|
// staged reduction) AND this is the main thread that owns it. The store is
|
|
// shared across in-process subagents (agent:*); a subagent must still
|
|
// autocompact its own oversized transcript instead of being suppressed by a
|
|
// reduction that only applies to the main transcript.
|
|
if (isMainThreadSource(querySource) && hasActiveReduction()) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if (skipTokenCheck) {
|
|
logForDebugging('autocompact: skipping token threshold check (forced)')
|
|
return true
|
|
}
|
|
|
|
const tokenCount = tokenCountWithEstimation(messages) - snipTokensFreed
|
|
const threshold = getAutoCompactThreshold(model)
|
|
const effectiveWindow = getEffectiveContextWindowSize(model)
|
|
|
|
logForDebugging(
|
|
`autocompact: tokens=${tokenCount} threshold=${threshold} effectiveWindow=${effectiveWindow}${snipTokensFreed > 0 ? ` snipFreed=${snipTokensFreed}` : ''}`,
|
|
)
|
|
|
|
const { isAboveAutoCompactThreshold } = calculateTokenWarningState(
|
|
tokenCount,
|
|
model,
|
|
)
|
|
|
|
return isAboveAutoCompactThreshold
|
|
}
|
|
|
|
export async function autoCompactIfNeeded(
|
|
messages: Message[],
|
|
toolUseContext: ToolUseContext,
|
|
cacheSafeParams: CacheSafeParams,
|
|
querySource?: QuerySource,
|
|
tracking?: AutoCompactTrackingState,
|
|
snipTokensFreed?: number,
|
|
): Promise<{
|
|
wasCompacted: boolean
|
|
compactionResult?: CompactionResult
|
|
consecutiveFailures?: number
|
|
nextRetryAtMs?: number
|
|
lastFailureAtMs?: number
|
|
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.
|
|
const forcedBy = tracking?.forceReason
|
|
if (tracking?.forceReason) {
|
|
tracking.forceReason = undefined
|
|
}
|
|
const shouldCompact = await shouldAutoCompact(
|
|
messages,
|
|
model,
|
|
querySource,
|
|
snipTokensFreed,
|
|
!!forcedBy,
|
|
)
|
|
|
|
if (!shouldCompact) {
|
|
if ((tracking?.consecutiveFailures ?? 0) > 0 || tracking?.nextRetryAtMs) {
|
|
return {
|
|
wasCompacted: false,
|
|
consecutiveFailures: 0,
|
|
circuitBreakerActive: false,
|
|
circuitBreakerTripped: false,
|
|
}
|
|
}
|
|
return { wasCompacted: false }
|
|
}
|
|
|
|
const now = Date.now()
|
|
const cooldownMs = getAutoCompactFailureCooldownMs()
|
|
const breakerState = resolveAutoCompactCircuitBreakerState({
|
|
tracking,
|
|
nowMs: now,
|
|
cooldownMs,
|
|
})
|
|
|
|
if (breakerState.action === 'skip') {
|
|
return {
|
|
wasCompacted: false,
|
|
consecutiveFailures: breakerState.consecutiveFailures,
|
|
nextRetryAtMs: breakerState.nextRetryAtMs,
|
|
circuitBreakerActive: true,
|
|
circuitBreakerTripped: false,
|
|
}
|
|
}
|
|
|
|
const effectiveTracking: AutoCompactTrackingState | undefined =
|
|
tracking && breakerState.wasHalfOpen
|
|
? {
|
|
...tracking,
|
|
consecutiveFailures: breakerState.effectiveConsecutiveFailures,
|
|
nextRetryAtMs: undefined,
|
|
}
|
|
: tracking
|
|
|
|
const contextWindow = getContextWindowForModel(model, getSdkBetas())
|
|
|
|
const partitioned = partitionContext(messages, {
|
|
contextWindow,
|
|
recentCount: 5,
|
|
})
|
|
const availableSpace = partitioned.canFitInWindow
|
|
? contextWindow - partitioned.totalTokens
|
|
: Math.floor(contextWindow * 0.1)
|
|
|
|
if (!partitioned.canFitInWindow && availableSpace > 1000) {
|
|
// Preserve system messages
|
|
const systemMessages = messages.filter(m => m.message?.role === 'system')
|
|
const nonSystemMessages = messages.filter(m => m.message?.role !== 'system')
|
|
|
|
const pruned = pruneByRelevance(nonSystemMessages, {
|
|
targetTokens: availableSpace,
|
|
preserveRecent: 3,
|
|
preserveTools: true,
|
|
preserveErrors: true,
|
|
})
|
|
|
|
// Combine preserved system + pruned
|
|
const finalMessages = [...systemMessages, ...pruned]
|
|
|
|
if (finalMessages.length > 0 && finalMessages.length < messages.length) {
|
|
logForDebugging(
|
|
`partition+prune: ${messages.length} -> ${finalMessages.length} messages`,
|
|
)
|
|
messages = finalMessages
|
|
}
|
|
}
|
|
|
|
const recompactionInfo: RecompactionInfo = {
|
|
isRecompactionInChain: effectiveTracking?.compacted === true,
|
|
turnsSincePreviousCompact: effectiveTracking?.turnCounter ?? -1,
|
|
previousCompactTurnId: effectiveTracking?.turnId,
|
|
autoCompactThreshold: getAutoCompactThreshold(model),
|
|
querySource,
|
|
}
|
|
|
|
// EXPERIMENT: Try session memory compaction first
|
|
const sessionMemoryResult = await trySessionMemoryCompaction(
|
|
messages,
|
|
toolUseContext.agentId,
|
|
recompactionInfo.autoCompactThreshold,
|
|
)
|
|
if (sessionMemoryResult) {
|
|
// Reset lastSummarizedMessageId since session memory compaction prunes messages
|
|
// and the old message UUID will no longer exist after the REPL replaces messages
|
|
setLastSummarizedMessageId(undefined)
|
|
runPostCompactCleanup(querySource)
|
|
// Reset cache read baseline so the post-compact drop isn't flagged as a
|
|
// break. compactConversation does this internally; SM-compact doesn't.
|
|
// BQ 2026-03-01: missing this made 20% of tengu_prompt_cache_break events
|
|
// false positives (systemPromptChanged=true, timeSinceLastAssistantMsg=-1).
|
|
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
|
|
notifyCompaction(querySource ?? 'compact', toolUseContext.agentId)
|
|
}
|
|
markPostCompaction()
|
|
return {
|
|
wasCompacted: true,
|
|
compactionResult: sessionMemoryResult,
|
|
consecutiveFailures: 0,
|
|
}
|
|
}
|
|
|
|
try {
|
|
const compactionResult = await compactConversation(
|
|
messages,
|
|
toolUseContext,
|
|
cacheSafeParams,
|
|
true, // Suppress user questions for autocompact
|
|
undefined, // No custom instructions for autocompact
|
|
true, // isAutoCompact
|
|
recompactionInfo,
|
|
)
|
|
|
|
// Reset lastSummarizedMessageId since legacy compaction replaces all messages
|
|
// and the old message UUID will no longer exist in the new messages array
|
|
setLastSummarizedMessageId(undefined)
|
|
runPostCompactCleanup(querySource)
|
|
|
|
return {
|
|
wasCompacted: true,
|
|
compactionResult,
|
|
// Reset failure count on success
|
|
consecutiveFailures: 0,
|
|
}
|
|
} catch (error) {
|
|
const wasUserAbort = hasExactErrorMessage(error, ERROR_MESSAGE_USER_ABORT)
|
|
if (wasUserAbort) {
|
|
return {
|
|
wasCompacted: false,
|
|
consecutiveFailures: breakerState.effectiveConsecutiveFailures,
|
|
nextRetryAtMs: breakerState.wasHalfOpen
|
|
? undefined
|
|
: tracking?.nextRetryAtMs,
|
|
circuitBreakerActive: false,
|
|
circuitBreakerTripped: false,
|
|
}
|
|
}
|
|
|
|
logError(error)
|
|
// Increment consecutive failure count for circuit breaker.
|
|
// The caller threads this through autoCompactTracking so the
|
|
// next query loop iteration can skip futile retry attempts until cooldown.
|
|
const nextFailures = Math.min(
|
|
breakerState.effectiveConsecutiveFailures + 1,
|
|
MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES,
|
|
)
|
|
const circuitBreakerTripped =
|
|
nextFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES
|
|
const failureAtMs = Date.now()
|
|
const nextRetryAtMs = circuitBreakerTripped
|
|
? failureAtMs + cooldownMs
|
|
: undefined
|
|
if (circuitBreakerTripped) {
|
|
logForDebugging(
|
|
`autocompact: circuit breaker tripped after ${nextFailures} consecutive failures — retrying after cooldown`,
|
|
{ level: 'warn' },
|
|
)
|
|
}
|
|
return {
|
|
wasCompacted: false,
|
|
consecutiveFailures: nextFailures,
|
|
nextRetryAtMs,
|
|
lastFailureAtMs: failureAtMs,
|
|
circuitBreakerActive: circuitBreakerTripped,
|
|
circuitBreakerTripped,
|
|
}
|
|
}
|
|
}
|