forked from ultraworkers/claw-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmicroCompact.ts
More file actions
530 lines (480 loc) · 19.1 KB
/
Copy pathmicroCompact.ts
File metadata and controls
530 lines (480 loc) · 19.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
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<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 = 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:<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 }
}