Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
20 kB · 530 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531import { feature } from 'bun:bundle'import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'import type { QuerySource } from '../../constants/querySource.js'import type { ToolUseContext } from '../../Tool.js'import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js'import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js'import { FILE_WRITE_TOOL_NAME } from '../../tools/FileWriteTool/prompt.js'import { GLOB_TOOL_NAME } from '../../tools/GlobTool/prompt.js'import { GREP_TOOL_NAME } from '../../tools/GrepTool/prompt.js'import { WEB_FETCH_TOOL_NAME } from '../../tools/WebFetchTool/prompt.js'import { WEB_SEARCH_TOOL_NAME } from '../../tools/WebSearchTool/prompt.js'import type { Message } from '../../types/message.js'import { logForDebugging } from '../../utils/debug.js'import { getMainLoopModel } from '../../utils/model/model.js'import { SHELL_TOOL_NAMES } from '../../utils/shell/shellToolUtils.js'import { jsonStringify } from '../../utils/slowOperations.js'import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent,} from '../analytics/index.js'import { notifyCacheDeletion } from '../api/promptCacheBreakDetection.js'import { roughTokenCountEstimation } from '../tokenEstimation.js'import { clearCompactWarningSuppression, suppressCompactWarning,} from './compactWarningState.js'import { getTimeBasedMCConfig, type TimeBasedMCConfig,} from './timeBasedMCConfig.js'
// Inline from utils/toolResultStorage.ts — importing that file pulls in// sessionStorage → utils/messages → services/api/errors, completing a// circular-deps loop back through this file via promptCacheBreakDetection.// Drift is caught by a test asserting equality with the source-of-truth.export const TIME_BASED_MC_CLEARED_MESSAGE = '[Old tool result content cleared]'
const IMAGE_MAX_TOKEN_SIZE = 2000
// Only compact these toolsconst COMPACTABLE_TOOLS = new Set<string>([ FILE_READ_TOOL_NAME, ...SHELL_TOOL_NAMES, GREP_TOOL_NAME, GLOB_TOOL_NAME, WEB_SEARCH_TOOL_NAME, WEB_FETCH_TOOL_NAME, FILE_EDIT_TOOL_NAME, FILE_WRITE_TOOL_NAME,])
// --- Cached microcompact state (ant-only, gated by feature('CACHED_MICROCOMPACT')) ---
// Lazy-initialized cached MC module and state to avoid importing in external builds.// The imports and state live inside feature() checks for dead code elimination.let cachedMCModule: typeof import('./cachedMicrocompact.js') | null = nulllet cachedMCState: import('./cachedMicrocompact.js').CachedMCState | null = nulllet pendingCacheEdits: | import('./cachedMicrocompact.js').CacheEditsBlock | null = null
async function getCachedMCModule(): Promise< typeof import('./cachedMicrocompact.js')> { if (!cachedMCModule) { cachedMCModule = await import('./cachedMicrocompact.js') } return cachedMCModule}
function ensureCachedMCState(): import('./cachedMicrocompact.js').CachedMCState { if (!cachedMCState && cachedMCModule) { cachedMCState = cachedMCModule.createCachedMCState() } if (!cachedMCState) { throw new Error( 'cachedMCState not initialized — getCachedMCModule() must be called first', ) } return cachedMCState}
/** * Get new pending cache edits to be included in the next API request. * Returns null if there are no new pending edits. * Clears the pending state (caller must pin them after insertion). */export function consumePendingCacheEdits(): | import('./cachedMicrocompact.js').CacheEditsBlock | null { const edits = pendingCacheEdits pendingCacheEdits = null return edits}
/** * Get all previously-pinned cache edits that must be re-sent at their * original positions for cache hits. */export function getPinnedCacheEdits(): import('./cachedMicrocompact.js').PinnedCacheEdits[] { if (!cachedMCState) { return [] } return cachedMCState.pinnedEdits}
/** * Pin a new cache_edits block to a specific user message position. * Called after inserting new edits so they are re-sent in subsequent calls. */export function pinCacheEdits( userMessageIndex: number, block: import('./cachedMicrocompact.js').CacheEditsBlock,): void { if (cachedMCState) { cachedMCState.pinnedEdits.push({ userMessageIndex, block }) }}
/** * Marks all registered tools as sent to the API. * Called after a successful API response. */export function markToolsSentToAPIState(): void { if (cachedMCState && cachedMCModule) { cachedMCModule.markToolsSentToAPI(cachedMCState) }}
export function resetMicrocompactState(): void { if (cachedMCState && cachedMCModule) { cachedMCModule.resetCachedMCState(cachedMCState) } pendingCacheEdits = null}
// Helper to calculate tool result tokensfunction calculateToolResultTokens(block: ToolResultBlockParam): number { if (!block.content) { return 0 }
if (typeof block.content === 'string') { return roughTokenCountEstimation(block.content) }
// Array of TextBlockParam | ImageBlockParam | DocumentBlockParam return block.content.reduce((sum, item) => { if (item.type === 'text') { return sum + roughTokenCountEstimation(item.text) } else if (item.type === 'image' || item.type === 'document') { // Images/documents are approximately 2000 tokens regardless of format return sum + IMAGE_MAX_TOKEN_SIZE } return sum }, 0)}
/** * Estimate token count for messages by extracting text content * Used for rough token estimation when we don't have accurate API counts * Pads estimate by 4/3 to be conservative since we're approximating */export function estimateMessageTokens(messages: Message[]): number { let totalTokens = 0
for (const message of messages) { if (message.type !== 'user' && message.type !== 'assistant') { continue }
if (!Array.isArray(message.message.content)) { continue }
for (const block of message.message.content) { if (block.type === 'text') { totalTokens += roughTokenCountEstimation(block.text) } else if (block.type === 'tool_result') { totalTokens += calculateToolResultTokens(block) } else if (block.type === 'image' || block.type === 'document') { totalTokens += IMAGE_MAX_TOKEN_SIZE } else if (block.type === 'thinking') { // Match roughTokenCountEstimationForBlock: count only the thinking // text, not the JSON wrapper or signature (signature is metadata, // not model-tokenized content). totalTokens += roughTokenCountEstimation(block.thinking) } else if (block.type === 'redacted_thinking') { totalTokens += roughTokenCountEstimation(block.data) } else if (block.type === 'tool_use') { // Match roughTokenCountEstimationForBlock: count name + input, // not the JSON wrapper or id field. totalTokens += roughTokenCountEstimation( block.name + jsonStringify(block.input ?? {}), ) } else { // server_tool_use, web_search_tool_result, etc. totalTokens += roughTokenCountEstimation(jsonStringify(block)) } } }
// Pad estimate by 4/3 to be conservative since we're approximating return Math.ceil(totalTokens * (4 / 3))}
export type PendingCacheEdits = { trigger: 'auto' deletedToolIds: string[] // Baseline cumulative cache_deleted_input_tokens from the previous API response, // used to compute the per-operation delta (the API value is sticky/cumulative) baselineCacheDeletedTokens: number}
export type MicrocompactResult = { messages: Message[] compactionInfo?: { pendingCacheEdits?: PendingCacheEdits }}
/** * Walk messages and collect tool_use IDs whose tool name is in * COMPACTABLE_TOOLS, in encounter order. Shared by both microcompact paths. */function collectCompactableToolIds(messages: Message[]): string[] { const ids: string[] = [] for (const message of messages) { if ( message.type === 'assistant' && Array.isArray(message.message.content) ) { for (const block of message.message.content) { if (block.type === 'tool_use' && COMPACTABLE_TOOLS.has(block.name)) { ids.push(block.id) } } } } return ids}
// Prefix-match because promptCategory.ts sets the querySource to// 'repl_main_thread:outputStyle:<style>' when a non-default output style// is active. The bare 'repl_main_thread' is only used for the default style.// query.ts:350/1451 use the same startsWith pattern; the pre-existing// cached-MC `=== 'repl_main_thread'` check was a latent bug — users with a// non-default output style were silently excluded from cached MC.function isMainThreadSource(querySource: QuerySource | undefined): boolean { return !querySource || querySource.startsWith('repl_main_thread')}
export async function microcompactMessages( messages: Message[], toolUseContext?: ToolUseContext, querySource?: QuerySource,): Promise<MicrocompactResult> { // Clear suppression flag at start of new microcompact attempt clearCompactWarningSuppression()
// Time-based trigger runs first and short-circuits. If the gap since the // last assistant message exceeds the threshold, the server cache has expired // and the full prefix will be rewritten regardless — so content-clear old // tool results now, before the request, to shrink what gets rewritten. // Cached MC (cache-editing) is skipped when this fires: editing assumes a // warm cache, and we just established it's cold. const timeBasedResult = maybeTimeBasedMicrocompact(messages, querySource) if (timeBasedResult) { return timeBasedResult }
// Only run cached MC for the main thread to prevent forked agents // (session_memory, prompt_suggestion, etc.) from registering their // tool_results in the global cachedMCState, which would cause the main // thread to try deleting tools that don't exist in its own conversation. if (feature('CACHED_MICROCOMPACT')) { const mod = await getCachedMCModule() const model = toolUseContext?.options.mainLoopModel ?? getMainLoopModel() if ( mod.isCachedMicrocompactEnabled() && mod.isModelSupportedForCacheEditing(model) && isMainThreadSource(querySource) ) { return await cachedMicrocompactPath(messages, querySource) } }
// Legacy microcompact path removed — tengu_cache_plum_violet is always true. // For contexts where cached microcompact is not available (external builds, // non-ant users, unsupported models, sub-agents), no compaction happens here; // autocompact handles context pressure instead. return { messages }}
/** * Cached microcompact path - uses cache editing API to remove tool results * without invalidating the cached prefix. * * Key differences from regular microcompact: * - Does NOT modify local message content (cache_reference and cache_edits are added at API layer) * - Uses count-based trigger/keep thresholds from GrowthBook config * - Takes precedence over regular microcompact (no disk persistence) * - Tracks tool results and queues cache edits for the API layer */async function cachedMicrocompactPath( messages: Message[], querySource: QuerySource | undefined,): Promise<MicrocompactResult> { const mod = await getCachedMCModule() const state = ensureCachedMCState() const config = mod.getCachedMCConfig()
const compactableToolIds = new Set(collectCompactableToolIds(messages)) // Second pass: register tool results grouped by user message for (const message of messages) { if (message.type === 'user' && Array.isArray(message.message.content)) { const groupIds: string[] = [] for (const block of message.message.content) { if ( block.type === 'tool_result' && compactableToolIds.has(block.tool_use_id) && !state.registeredTools.has(block.tool_use_id) ) { mod.registerToolResult(state, block.tool_use_id) groupIds.push(block.tool_use_id) } } mod.registerToolMessage(state, groupIds) } }
const toolsToDelete = mod.getToolResultsToDelete(state)
if (toolsToDelete.length > 0) { // Create and queue the cache_edits block for the API layer const cacheEdits = mod.createCacheEditsBlock(state, toolsToDelete) if (cacheEdits) { pendingCacheEdits = cacheEdits }
logForDebugging( `Cached MC deleting ${toolsToDelete.length} tool(s): ${toolsToDelete.join(', ')}`, )
// Log the event logEvent('tengu_cached_microcompact', { toolsDeleted: toolsToDelete.length, deletedToolIds: toolsToDelete.join( ',', ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, activeToolCount: state.toolOrder.length - state.deletedRefs.size, triggerType: 'auto' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, threshold: config.triggerThreshold, keepRecent: config.keepRecent, })
// Suppress warning after successful compaction suppressCompactWarning()
// Notify cache break detection that cache reads will legitimately drop if (feature('PROMPT_CACHE_BREAK_DETECTION')) { // Pass the actual querySource — isMainThreadSource now prefix-matches // so output-style variants enter here, and getTrackingKey keys on the // full source string, not the 'repl_main_thread' prefix. notifyCacheDeletion(querySource ?? 'repl_main_thread') }
// Return messages unchanged - cache_reference and cache_edits are added at API layer // Boundary message is deferred until after API response so we can use // actual cache_deleted_input_tokens from the API instead of client-side estimates // Capture the baseline cumulative cache_deleted_input_tokens from the last // assistant message so we can compute a per-operation delta after the API call const lastAsst = messages.findLast(m => m.type === 'assistant') const baseline = lastAsst?.type === 'assistant' ? (( lastAsst.message.usage as unknown as Record< string, number | undefined > )?.cache_deleted_input_tokens ?? 0) : 0
return { messages, compactionInfo: { pendingCacheEdits: { trigger: 'auto', deletedToolIds: toolsToDelete, baselineCacheDeletedTokens: baseline, }, }, } }
// No compaction needed, return messages unchanged return { messages }}
/** * Time-based microcompact: when the gap since the last main-loop assistant * message exceeds the configured threshold, content-clear all but the most * recent N compactable tool results. * * Returns null when the trigger doesn't fire (disabled, wrong source, gap * under threshold, nothing to clear) — caller falls through to other paths. * * Unlike cached MC, this mutates message content directly. The cache is cold, * so there's no cached prefix to preserve via cache_edits. *//** * Check whether the time-based trigger should fire for this request. * * Returns the measured gap (minutes since last assistant message) when the * trigger fires, or null when it doesn't (disabled, wrong source, under * threshold, no prior assistant, unparseable timestamp). * * Extracted so other pre-request paths (e.g. snip force-apply) can consult * the same predicate without coupling to the tool-result clearing action. */export function evaluateTimeBasedTrigger( messages: Message[], querySource: QuerySource | undefined,): { gapMinutes: number; config: TimeBasedMCConfig } | null { const config = getTimeBasedMCConfig() // Require an explicit main-thread querySource. isMainThreadSource treats // undefined as main-thread (for cached-MC backward-compat), but several // callers (/context, /compact, analyzeContext) invoke microcompactMessages // without a source for analysis-only purposes — they should not trigger. if (!config.enabled || !querySource || !isMainThreadSource(querySource)) { return null } const lastAssistant = messages.findLast(m => m.type === 'assistant') if (!lastAssistant) { return null } const gapMinutes = (Date.now() - new Date(lastAssistant.timestamp).getTime()) / 60_000 if (!Number.isFinite(gapMinutes) || gapMinutes < config.gapThresholdMinutes) { return null } return { gapMinutes, config }}
function maybeTimeBasedMicrocompact( messages: Message[], querySource: QuerySource | undefined,): MicrocompactResult | null { const trigger = evaluateTimeBasedTrigger(messages, querySource) if (!trigger) { return null } const { gapMinutes, config } = trigger
const compactableIds = collectCompactableToolIds(messages)
// Floor at 1: slice(-0) returns the full array (paradoxically keeps // everything), and clearing ALL results leaves the model with zero working // context. Neither degenerate is sensible — always keep at least the last. const keepRecent = Math.max(1, config.keepRecent) const keepSet = new Set(compactableIds.slice(-keepRecent)) const clearSet = new Set(compactableIds.filter(id => !keepSet.has(id)))
if (clearSet.size === 0) { return null }
let tokensSaved = 0 const result: Message[] = messages.map(message => { if (message.type !== 'user' || !Array.isArray(message.message.content)) { return message } let touched = false const newContent = message.message.content.map(block => { if ( block.type === 'tool_result' && clearSet.has(block.tool_use_id) && block.content !== TIME_BASED_MC_CLEARED_MESSAGE ) { tokensSaved += calculateToolResultTokens(block) touched = true return { ...block, content: TIME_BASED_MC_CLEARED_MESSAGE } } return block }) if (!touched) return message return { ...message, message: { ...message.message, content: newContent }, } })
if (tokensSaved === 0) { return null }
logEvent('tengu_time_based_microcompact', { gapMinutes: Math.round(gapMinutes), gapThresholdMinutes: config.gapThresholdMinutes, toolsCleared: clearSet.size, toolsKept: keepSet.size, keepRecent: config.keepRecent, tokensSaved, })
logForDebugging( `[TIME-BASED MC] gap ${Math.round(gapMinutes)}min > ${config.gapThresholdMinutes}min, cleared ${clearSet.size} tool results (~${tokensSaved} tokens), kept last ${keepSet.size}`, )
suppressCompactWarning() // Cached-MC state (module-level) holds tool IDs registered on prior turns. // We just content-cleared some of those tools AND invalidated the server // cache by changing prompt content. If cached-MC runs next turn with the // stale state, it would try to cache_edit tools whose server-side entries // no longer exist. Reset it. resetMicrocompactState() // We just changed the prompt content — the next response's cache read will // be low, but that's us, not a break. Tell the detector to expect a drop. // notifyCacheDeletion (not notifyCompaction) because it's already imported // here and achieves the same false-positive suppression — adding the second // symbol to the import was flagged by the circular-deps check. // Pass the actual querySource: getTrackingKey returns the full source string // (e.g. 'repl_main_thread:outputStyle:custom'), not just the prefix. if (feature('PROMPT_CACHE_BREAK_DETECTION') && querySource) { notifyCacheDeletion(querySource) }
return { messages: result }}