mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
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:
parent
1464ef538e
commit
f4eee4e3a5
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
28
src/query.ts
28
src/query.ts
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
151
tests/compactionThresholds.test.ts
Normal file
151
tests/compactionThresholds.test.ts
Normal 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)
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user