import { 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 tools const COMPACTABLE_TOOLS = new Set([ 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 = null let cachedMCState: import('./cachedMicrocompact.js').CachedMCState | null = null let 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 tokens function 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: