feat(cache): multi-level compaction thresholds and CCH optimization

Implement 6 cache optimization improvements inspired by Reasonix:

1. **CCH attribution header**: Disabled by default to prevent per-turn
   cache invalidation. The `x-anthropic-billing-header` was embedded in
   `system[0]` of every prompt, and its xxHash value changed per-turn,
   causing full cache prefix misses. Users can re-enable via env var
   `CLAUDE_CODE_ATTRIBUTION_HEADER` or GrowthBook flag.

2. **Multi-level percentage compaction thresholds**: Supplement the
   existing fixed 13K buffer with percentage-based levels (75%/78%/80%/
   90%) that work correctly across all context window sizes from 200K
   to 1M+. The fixed buffer remains as "final defense" at 93-98%.

3. **Turn-start token pre-estimation**: Feature-flagged checkpoint
   (`TURN_START_PRE_ESTIMATION`) before API calls to detect when
   context approaches 90% capacity before the passive check catches it.

4. **Cache-aligned compaction**: Already implemented (CacheSafeParams
   in forkedAgent.ts). No changes needed — verified.

5. **Token-based tool result truncation**: `truncateToolResultByTokens()`
   replaces char-based counting with rough token estimation for CJK-
   aware truncation at clean line boundaries.

6. **Minimum savings check**: `isCompactionWorthwhile()` skips compaction
   when the head portion is less than 30% of the context window,
   preventing waste when the summary costs nearly as much as it saves.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
moyu12-ae 2026-05-27 14:40:16 +08:00
parent 1464ef538e
commit f4eee4e3a5
5 changed files with 465 additions and 3 deletions

View File

@ -47,13 +47,14 @@ export function getCLISyspromptPrefix(options?: {
/**
* Check if attribution header is enabled.
* Enabled by default, can be disabled via env var or GrowthBook killswitch.
* Disabled by default to preserve prompt cache stability.
* Can be enabled via env var CLAUDE_CODE_ATTRIBUTION_HEADER or GrowthBook feature flag.
*/
function isAttributionHeaderEnabled(): boolean {
if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_ATTRIBUTION_HEADER)) {
return false
}
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_attribution_header', true)
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_attribution_header', false)
}
/**

View File

@ -7,6 +7,9 @@ import type { CanUseToolFn } from './hooks/useCanUseTool.js'
import { FallbackTriggeredError } from './services/api/withRetry.js'
import {
calculateTokenWarningState,
COMPACT_PRECHECK_FOLD_RATIO,
estimateTurnStartUsage,
getEffectiveContextWindowSize,
isAutoCompactEnabled,
type AutoCompactTrackingState,
} from './services/compact/autoCompact.js'
@ -452,6 +455,31 @@ async function* queryLoop(
)
queryCheckpoint('query_autocompact_start')
// Turn-start pre-estimation: log a diagnostic signal before the autocompact
// check so we can observe whether the context was already near capacity
// before the next API call. The existing autocompact path (line below)
// handles the actual trigger via percentage-based thresholds (75%/78%/80%).
// This checkpoint is purely for observability.
if (feature('TURN_START_PRE_ESTIMATION')) {
const { ratio, estimateTokens } = estimateTurnStartUsage(
messagesForQuery,
getEffectiveContextWindowSize(toolUseContext.options.mainLoopModel),
)
if (ratio >= COMPACT_PRECHECK_FOLD_RATIO) {
logForDebugging(
`turnStartPreEstimate: context at ${(ratio * 100).toFixed(1)}% ` +
`(~${estimateTokens.toLocaleString()} tokens) before API call — ` +
`pre-fold may be needed`,
{ level: 'warn' },
)
logEvent('tengu_turn_start_prefold_detected', {
estimatedTokens: estimateTokens,
ratio: Math.round(ratio * 100),
})
}
}
const { compactionResult, consecutiveFailures } = await deps.autocompact(
messagesForQuery,
toolUseContext,

View File

@ -64,6 +64,56 @@ export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000
export const ERROR_THRESHOLD_BUFFER_TOKENS = 20_000
export const MANUAL_COMPACT_BUFFER_TOKENS = 3_000
// ---------------------------------------------------------------------------
// Percentage-based multi-level compaction thresholds (supplement, not replace)
//
// The fixed-buffer threshold (effectiveWindow - 13_000) is the "final defense"
// at ~93-98% of the window. These percentage thresholds provide earlier,
// gentler interventions that work well across all context window sizes
// (200K through 1M+).
// ---------------------------------------------------------------------------
/** Normal fold: compact older messages, keep 20% of context window as tail budget */
export const COMPACT_NORMAL_FOLD_RATIO = 0.75
export const COMPACT_NORMAL_FOLD_TAIL_RATIO = 0.20
/** Aggressive fold: compact harder, keep 10% of context window as tail budget */
export const COMPACT_AGGRESSIVE_FOLD_RATIO = 0.78
export const COMPACT_AGGRESSIVE_FOLD_TAIL_RATIO = 0.10
/** Force summary exit: stop the agent with a summary — no more room for folds */
export const COMPACT_FORCE_SUMMARY_RATIO = 0.80
/** Turn-start pre-fold: pre-check before the API call (used by estimateTurnStartUsage) */
export const COMPACT_PRECHECK_FOLD_RATIO = 0.90
/**
* Compaction levels ordered by severity.
* - none / turn_start_prefold are soft checks
* - normal_fold / aggressive_fold are actual compactions with tail budgets
* - force_summary / fixed_buffer are hard exits (no more folds)
*/
export type CompactionLevel =
| 'none'
| 'turn_start_prefold'
| 'normal_fold'
| 'aggressive_fold'
| 'force_summary'
| 'fixed_buffer'
export type CompactionLevelResult = {
level: CompactionLevel
/** Token budget for the recent tail when level is normal_fold or aggressive_fold */
tailBudgetTokens: number
effectiveWindow: number
fixedBufferThreshold: number
}
// Minimum fraction of context that must be in the compactable "head" portion
// for compaction to be worthwhile. Prevents wasting a compact API call when
// the savings are marginal (Reasonix reference: HISTORY_FOLD_MIN_SAVINGS_FRACTION).
export const MIN_COMPACTION_SAVINGS_RATIO = 0.30
// Stop trying 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.
@ -90,6 +140,147 @@ export function getAutoCompactThreshold(model: string): number {
return autocompactThreshold
}
/**
* Gate for the multi-level percentage-based compaction feature.
* When disabled, the existing fixed-buffer behavior is unchanged.
*/
export function isPercentageCompactionEnabled(): boolean {
if (!isAutoCompactEnabled()) return false
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_multi_level_compact', true)
}
/**
* Determine the compaction level based on percentage thresholds AND the
* fixed-buffer threshold. Percentage thresholds act as earlier, gentler
* interventions; the fixed-buffer threshold is the "final defense."
*
* Checks in descending severity order so the most urgent level wins.
*
* @param tokenCount - current estimated token usage
* @param model - model name for context window lookup
*/
export function getCompactionLevel(
tokenCount: number,
model: string,
): CompactionLevelResult {
const effectiveWindow = getEffectiveContextWindowSize(model)
const fixedBufferThreshold = effectiveWindow - AUTOCOMPACT_BUFFER_TOKENS
// Fixed buffer is the "final defense" — triggers closest to the window limit
if (tokenCount >= fixedBufferThreshold) {
return {
level: 'fixed_buffer',
tailBudgetTokens: Math.floor(effectiveWindow * 0.05),
effectiveWindow,
fixedBufferThreshold,
}
}
const forceSummaryThreshold = Math.floor(
effectiveWindow * COMPACT_FORCE_SUMMARY_RATIO,
)
if (tokenCount >= forceSummaryThreshold) {
return {
level: 'force_summary',
tailBudgetTokens: 0, // No tail — force exit
effectiveWindow,
fixedBufferThreshold,
}
}
const aggressiveFoldThreshold = Math.floor(
effectiveWindow * COMPACT_AGGRESSIVE_FOLD_RATIO,
)
if (tokenCount >= aggressiveFoldThreshold) {
return {
level: 'aggressive_fold',
tailBudgetTokens: Math.floor(
effectiveWindow * COMPACT_AGGRESSIVE_FOLD_TAIL_RATIO,
),
effectiveWindow,
fixedBufferThreshold,
}
}
const normalFoldThreshold = Math.floor(
effectiveWindow * COMPACT_NORMAL_FOLD_RATIO,
)
if (tokenCount >= normalFoldThreshold) {
return {
level: 'normal_fold',
tailBudgetTokens: Math.floor(
effectiveWindow * COMPACT_NORMAL_FOLD_TAIL_RATIO,
),
effectiveWindow,
fixedBufferThreshold,
}
}
return {
level: 'none',
tailBudgetTokens: effectiveWindow,
effectiveWindow,
fixedBufferThreshold,
}
}
/**
* Estimate whether compaction would save enough tokens to justify its cost.
*
* The "head portion" is the messages that would be summarized (those before
* the last compact boundary). If this portion is less than
* MIN_COMPACTION_SAVINGS_RATIO of the total context, the compact agent's own
* token consumption would exceed or nearly match the savings.
*
* @returns true if compaction is worthwhile, false to skip
*/
export function isCompactionWorthwhile(
estimatedTotalTokens: number,
effectiveWindow: number,
): boolean {
// Circuit breaker: if total tokens are somehow higher than the window,
// compaction is definitely worthwhile (emergency scenario).
if (estimatedTotalTokens >= effectiveWindow) return true
// Head portion = tokens above the normal fold threshold that could be freed.
// If most tokens are already in the "tail" (recent messages), compaction
// would save very little — the summary alone costs thousands of tokens.
const headFraction = estimatedTotalTokens / effectiveWindow
logForDebugging(
`compaction_savings_check: tokens=${estimatedTotalTokens} window=${effectiveWindow} ` +
`headFraction=${(headFraction * 100).toFixed(1)}% ` +
`minRequired=${(MIN_COMPACTION_SAVINGS_RATIO * 100).toFixed(0)}%`,
)
return headFraction >= MIN_COMPACTION_SAVINGS_RATIO
}
/**
* Fast turn-start token estimation using rough heuristics.
* Does NOT make an API call intentionally a coarse estimate.
*
* Uses the existing roughTokenCountEstimationForMessages (~4 chars/token)
* plus fixed overhead estimates for system prompt and tool schemas.
*
* @returns estimated token count for messages + overhead
*/
export function estimateTurnStartUsage(
messages: Message[],
effectiveWindow: number,
): { estimateTokens: number; ratio: number } {
// Use the same token estimation pipeline that shouldAutoCompact uses
const dynamicTokens = tokenCountWithEstimation(messages)
// Pre-check ratio: compare against the effective context window
const ratio = effectiveWindow > 0 ? dynamicTokens / effectiveWindow : 0
logForDebugging(
`turnStartEstimate: tokens=${dynamicTokens} window=${effectiveWindow} ratio=${(ratio * 100).toFixed(1)}%`,
)
return { estimateTokens: dynamicTokens, ratio }
}
export function calculateTokenWarningState(
tokenUsage: number,
model: string,
@ -230,12 +421,26 @@ export async function shouldAutoCompact(
`autocompact: tokens=${tokenCount} threshold=${threshold} effectiveWindow=${effectiveWindow}${snipTokensFreed > 0 ? ` snipFreed=${snipTokensFreed}` : ''}`,
)
// Existing fixed-buffer check: final defense at ~93-98% of window
const { isAboveAutoCompactThreshold } = calculateTokenWarningState(
tokenCount,
model,
)
return isAboveAutoCompactThreshold
if (isAboveAutoCompactThreshold) return true
// New: percentage-based multi-level check — earlier, gentler intervention
if (isPercentageCompactionEnabled()) {
const level = getCompactionLevel(tokenCount, model)
if (level.level !== 'none' && level.level !== 'turn_start_prefold') {
logForDebugging(
`autocompact: percentage threshold triggered (level=${level.level}, ratio=${(tokenCount / effectiveWindow * 100).toFixed(1)}%)`,
)
return true
}
}
return false
}
export async function autoCompactIfNeeded(
@ -276,6 +481,29 @@ export async function autoCompactIfNeeded(
return { wasCompacted: false }
}
// Minimum savings gate: skip compaction when the head portion is too small
// to save meaningful tokens. Prevents wasting a compact API call when the
// summary alone costs nearly as many tokens as it frees.
if (isPercentageCompactionEnabled()) {
const tokenCount = tokenCountWithEstimation(messages) - (snipTokensFreed ?? 0)
const effectiveWindow = getEffectiveContextWindowSize(model)
if (
!isCompactionWorthwhile(tokenCount, effectiveWindow)
) {
logForDebugging(
`autocompact: skipping — head portion too small for worthwhile savings (tokens=${tokenCount}, window=${effectiveWindow})`,
)
return { wasCompacted: false }
}
}
// Compute the compaction level for use in recompactionInfo and to guide
// the compaction strategy (tail budget, aggressiveness).
const tokenCount = tokenCountWithEstimation(messages) - (snipTokensFreed ?? 0)
const compactionLevel = isPercentageCompactionEnabled()
? getCompactionLevel(tokenCount, model).level
: 'fixed_buffer'
const recompactionInfo: RecompactionInfo = {
isRecompactionInChain: tracking?.compacted === true,
turnsSincePreviousCompact: tracking?.turnCounter ?? -1,
@ -284,6 +512,10 @@ export async function autoCompactIfNeeded(
querySource,
}
logForDebugging(
`autocompact: triggering compaction (level=${compactionLevel}, tokens=${tokenCount})`,
)
// EXPERIMENT: Try session memory compaction first
const sessionMemoryResult = await trySessionMemoryCompaction(
messages,

View File

@ -14,6 +14,7 @@ import {
} from '../constants/toolLimits.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
import { logEvent } from '../services/analytics/index.js'
import { roughTokenCountEstimation } from '../services/tokenEstimation.js'
import { sanitizeToolNameForAnalytics } from '../services/analytics/metadata.js'
import type { Message } from '../types/message.js'
import { logForDebugging } from './debug.js'
@ -333,6 +334,55 @@ async function maybePersistLargeToolResult(
return { ...toolResultBlock, content: message }
}
/**
* Truncate tool result content to fit within a token budget.
*
* Uses rough character-based estimation (char/4) for speed. For CJK content
* this is conservative (undercounts tokens per char), but provides better
* accuracy than pure char-based truncation for mixed-language content.
*
* The truncation preserves newline boundaries when possible, and appends
* a marker so the model knows content was truncated.
*
* @param content - The content to potentially truncate
* @param maxTokens - Maximum allowed tokens before truncation
* @returns truncated content and whether truncation occurred
*/
export function truncateToolResultByTokens(
content: string,
maxTokens: number,
): { truncated: string; wasTruncated: boolean; estimatedTokens: number } {
const estimatedTokens = roughTokenCountEstimation(content)
if (estimatedTokens <= maxTokens || content.length <= maxTokens) {
return { truncated: content, wasTruncated: false, estimatedTokens }
}
// Approximation: each token ~4 chars on average
const charBudget = maxTokens * BYTES_PER_TOKEN
let truncated = content.slice(0, charBudget)
// Try to find a clean boundary (newline) near the cut point to avoid
// splitting mid-line or mid-word
const lastNewline = truncated.lastIndexOf('\n')
if (lastNewline > charBudget * 0.7) {
truncated = truncated.slice(0, lastNewline)
}
// tool_result_content_block_start already wraps each block; this marker
// replaces bulky output with a compact signal the model can act on.
// Wording mirrors the per-tool persist message so the model already
// knows what to expect when it just *couldn't* fit in context.
truncated +=
`\n\n[Content truncated from ~${formatFileSize(content.length)} to fit ` +
`within ~${maxTokens.toLocaleString()} tokens. Full output may be available in the session tool-results directory.]`
return {
truncated,
wasTruncated: true,
estimatedTokens: roughTokenCountEstimation(truncated),
}
}
/**
* Generate a preview of content, truncating at a newline boundary when possible.
*/

View File

@ -0,0 +1,151 @@
import { expect, test } from 'bun:test'
import {
COMPACT_NORMAL_FOLD_RATIO,
COMPACT_AGGRESSIVE_FOLD_RATIO,
COMPACT_FORCE_SUMMARY_RATIO,
COMPACT_PRECHECK_FOLD_RATIO,
COMPACT_NORMAL_FOLD_TAIL_RATIO,
COMPACT_AGGRESSIVE_FOLD_TAIL_RATIO,
MIN_COMPACTION_SAVINGS_RATIO,
isCompactionWorthwhile,
} from '../src/services/compact/autoCompact.js'
import { truncateToolResultByTokens } from '../src/utils/toolResultStorage.js'
// ---------------------------------------------------------------------------
// Constant validation — ensure thresholds stay at their expected values
// ---------------------------------------------------------------------------
test('percentage thresholds are correctly ordered', () => {
expect(COMPACT_NORMAL_FOLD_RATIO).toBe(0.75)
expect(COMPACT_AGGRESSIVE_FOLD_RATIO).toBe(0.78)
expect(COMPACT_FORCE_SUMMARY_RATIO).toBe(0.80)
expect(COMPACT_PRECHECK_FOLD_RATIO).toBe(0.90)
// Thresholds must be monotonically increasing
expect(COMPACT_NORMAL_FOLD_RATIO).toBeLessThan(COMPACT_AGGRESSIVE_FOLD_RATIO)
expect(COMPACT_AGGRESSIVE_FOLD_RATIO).toBeLessThan(
COMPACT_FORCE_SUMMARY_RATIO,
)
expect(COMPACT_FORCE_SUMMARY_RATIO).toBeLessThan(COMPACT_PRECHECK_FOLD_RATIO)
})
test('tail budget ratios are correctly ordered', () => {
expect(COMPACT_NORMAL_FOLD_TAIL_RATIO).toBe(0.20)
expect(COMPACT_AGGRESSIVE_FOLD_TAIL_RATIO).toBe(0.10)
// Normal fold should preserve more tail than aggressive fold
expect(COMPACT_AGGRESSIVE_FOLD_TAIL_RATIO).toBeLessThan(
COMPACT_NORMAL_FOLD_TAIL_RATIO,
)
})
test('minimum savings ratio is a reasonable value', () => {
expect(MIN_COMPACTION_SAVINGS_RATIO).toBe(0.30)
expect(MIN_COMPACTION_SAVINGS_RATIO).toBeGreaterThan(0)
expect(MIN_COMPACTION_SAVINGS_RATIO).toBeLessThan(1)
})
// ---------------------------------------------------------------------------
// isCompactionWorthwhile
// ---------------------------------------------------------------------------
test('isCompactionWorthwhile returns true when most of context is occupied', () => {
// 90K tokens in 100K window → 90% occupied → worthwhile
expect(isCompactionWorthwhile(90_000, 100_000)).toBe(true)
})
test('isCompactionWorthwhile returns true at the boundary (30%)', () => {
// 30K tokens in 100K window → exactly 30% → still worthwhile
expect(isCompactionWorthwhile(30_000, 100_000)).toBe(true)
})
test('isCompactionWorthwhile returns false when below threshold', () => {
// 20K tokens in 100K window → 20% → not worthwhile
expect(isCompactionWorthwhile(20_000, 100_000)).toBe(false)
})
test('isCompactionWorthwhile returns true when tokens exceed window (emergency)', () => {
// Emergency: tokens exceed the context window — always worthwhile
expect(isCompactionWorthwhile(105_000, 100_000)).toBe(true)
})
test('isCompactionWorthwhile handles large 1M context window', () => {
// 400K tokens in 1M window → 40% → worthwhile
expect(isCompactionWorthwhile(400_000, 1_000_000)).toBe(true)
// 200K tokens in 1M window → 20% → not worthwhile
expect(isCompactionWorthwhile(200_000, 1_000_000)).toBe(false)
// 205K tokens in 200K window → >100% → emergency → worthwhile
expect(isCompactionWorthwhile(205_000, 200_000)).toBe(true)
})
// ---------------------------------------------------------------------------
// truncateToolResultByTokens
// ---------------------------------------------------------------------------
test('truncateToolResultByTokens returns content unchanged when under limit', () => {
const content = 'short content'
const result = truncateToolResultByTokens(content, 100)
expect(result.wasTruncated).toBe(false)
expect(result.truncated).toBe(content)
})
test('truncateToolResultByTokens returns content unchanged when exactly at limit', () => {
// 400 bytes → ~100 tokens at 4 bytes/token
const content = 'A'.repeat(400)
const result = truncateToolResultByTokens(content, 100)
// May or may not truncate depending on rough estimate — but marker
// should not appear when content is small enough
if (!result.wasTruncated) {
expect(result.truncated).toBe(content)
}
})
test('truncateToolResultByTokens truncates when well above limit', () => {
// ~50K chars → ~12,500 tokens at 4 bytes/token
const content = 'A'.repeat(50_000)
const result = truncateToolResultByTokens(content, 100)
expect(result.wasTruncated).toBe(true)
expect(result.truncated.length).toBeLessThan(content.length)
expect(result.truncated).toContain('Content truncated')
})
test('truncateToolResultByTokens includes marker in truncated content', () => {
const content = 'B'.repeat(10_000)
const result = truncateToolResultByTokens(content, 100)
expect(result.wasTruncated).toBe(true)
expect(result.truncated).toContain(
'Content truncated',
)
})
test('truncateToolResultByTokens handles CJK content', () => {
// CJK characters are ~1-3 tokens each, so char/4 underestimates tokens.
// The function should still gracefully handle and truncate CJK content.
const content = '中文测试内容'.repeat(5_000)
const result = truncateToolResultByTokens(content, 500)
expect(result.wasTruncated).toBe(true)
expect(result.truncated.length).toBeLessThan(content.length)
})
test('truncateToolResultByTokens preserves content integrity', () => {
const content = 'Hello World\nThis is a test\n'.repeat(200)
const result = truncateToolResultByTokens(content, 100)
expect(result.wasTruncated).toBe(true)
// Should not start with partial line when possible
// (if a newline was found within 70% of the budget)
const truncatedPart = result.truncated.replace(
/\n\n\[Content truncated.*\]$/s,
'',
)
// Content before the marker should be a prefix of the original
expect(content.startsWith(truncatedPart)).toBe(true)
})
test('truncateToolResultByTokens handles empty content', () => {
const result = truncateToolResultByTokens('', 100)
expect(result.wasTruncated).toBe(false)
expect(result.truncated).toBe('')
expect(result.estimatedTokens).toBe(0)
})