Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
61 kB · 1705 lines
TypeScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706import { feature } from 'bun:bundle'import type { UUID } from 'crypto'import uniqBy from 'lodash-es/uniqBy.js'
/* eslint-disable @typescript-eslint/no-require-imports */const sessionTranscriptModule = feature('KAIROS') ? (require('../sessionTranscript/sessionTranscript.js') as typeof import('../sessionTranscript/sessionTranscript.js')) : null
import { APIUserAbortError } from '@anthropic-ai/sdk'import { markPostCompaction } from 'src/bootstrap/state.js'import { getInvokedSkillsForAgent } from '../../bootstrap/state.js'import type { QuerySource } from '../../constants/querySource.js'import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'import type { Tool, ToolUseContext } from '../../Tool.js'import type { LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js'import { FileReadTool } from '../../tools/FileReadTool/FileReadTool.js'import { FILE_READ_TOOL_NAME, FILE_UNCHANGED_STUB,} from '../../tools/FileReadTool/prompt.js'import { ToolSearchTool } from '../../tools/ToolSearchTool/ToolSearchTool.js'import type { AgentId } from '../../types/ids.js'import type { AssistantMessage, AttachmentMessage, HookResultMessage, Message, PartialCompactDirection, SystemCompactBoundaryMessage, SystemMessage, UserMessage,} from '../../types/message.js'import { createAttachmentMessage, generateFileAttachment, getAgentListingDeltaAttachment, getDeferredToolsDeltaAttachment, getMcpInstructionsDeltaAttachment,} from '../../utils/attachments.js'import { getMemoryPath } from '../../utils/config.js'import { COMPACT_MAX_OUTPUT_TOKENS } from '../../utils/context.js'import { analyzeContext, tokenStatsToStatsigMetrics,} from '../../utils/contextAnalysis.js'import { logForDebugging } from '../../utils/debug.js'import { hasExactErrorMessage } from '../../utils/errors.js'import { cacheToObject } from '../../utils/fileStateCache.js'import { type CacheSafeParams, runForkedAgent,} from '../../utils/forkedAgent.js'import { executePostCompactHooks, executePreCompactHooks,} from '../../utils/hooks.js'import { logError } from '../../utils/log.js'import { MEMORY_TYPE_VALUES } from '../../utils/memory/types.js'import { createCompactBoundaryMessage, createUserMessage, getAssistantMessageText, getLastAssistantMessage, getMessagesAfterCompactBoundary, isCompactBoundaryMessage, normalizeMessagesForAPI,} from '../../utils/messages.js'import { expandPath } from '../../utils/path.js'import { getPlan, getPlanFilePath } from '../../utils/plans.js'import { isSessionActivityTrackingActive, sendSessionActivitySignal,} from '../../utils/sessionActivity.js'import { processSessionStartHooks } from '../../utils/sessionStart.js'import { getTranscriptPath, reAppendSessionMetadata,} from '../../utils/sessionStorage.js'import { sleep } from '../../utils/sleep.js'import { jsonStringify } from '../../utils/slowOperations.js'/* eslint-enable @typescript-eslint/no-require-imports */import { asSystemPrompt } from '../../utils/systemPromptType.js'import { getTaskOutputPath } from '../../utils/task/diskOutput.js'import { getTokenUsage, tokenCountFromLastAPIResponse, tokenCountWithEstimation,} from '../../utils/tokens.js'import { extractDiscoveredToolNames, isToolSearchEnabled,} from '../../utils/toolSearch.js'import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent,} from '../analytics/index.js'import { getMaxOutputTokensForModel, queryModelWithStreaming,} from '../api/claude.js'import { getPromptTooLongTokenGap, PROMPT_TOO_LONG_ERROR_MESSAGE, startsWithApiErrorPrefix,} from '../api/errors.js'import { notifyCompaction } from '../api/promptCacheBreakDetection.js'import { getRetryDelay } from '../api/withRetry.js'import { logPermissionContextForAnts } from '../internalLogging.js'import { roughTokenCountEstimation, roughTokenCountEstimationForMessages,} from '../tokenEstimation.js'import { groupMessagesByApiRound } from './grouping.js'import { getCompactPrompt, getCompactUserSummaryMessage, getPartialCompactPrompt,} from './prompt.js'
export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5export const POST_COMPACT_TOKEN_BUDGET = 50_000export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000// Skills can be large (verify=18.7KB, claude-api=20.1KB). Previously re-injected// unbounded on every compact → 5-10K tok/compact. Per-skill truncation beats// dropping — instructions at the top of a skill file are usually the critical// part. Budget sized to hold ~5 skills at the per-skill cap.export const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000const MAX_COMPACT_STREAMING_RETRIES = 2
/** * Strip image blocks from user messages before sending for compaction. * Images are not needed for generating a conversation summary and can * cause the compaction API call itself to hit the prompt-too-long limit, * especially in CCD sessions where users frequently attach images. * Replaces image blocks with a text marker so the summary still notes * that an image was shared. * * Note: Only user messages contain images (either directly attached or within * tool_result content from tools). Assistant messages contain text, tool_use, * and thinking blocks but not images. */export function stripImagesFromMessages(messages: Message[]): Message[] { return messages.map(message => { if (message.type !== 'user') { return message }
const content = message.message.content if (!Array.isArray(content)) { return message }
let hasMediaBlock = false const newContent = content.flatMap(block => { if (block.type === 'image') { hasMediaBlock = true return [{ type: 'text' as const, text: '[image]' }] } if (block.type === 'document') { hasMediaBlock = true return [{ type: 'text' as const, text: '[document]' }] } // Also strip images/documents nested inside tool_result content arrays if (block.type === 'tool_result' && Array.isArray(block.content)) { let toolHasMedia = false const newToolContent = block.content.map(item => { if (item.type === 'image') { toolHasMedia = true return { type: 'text' as const, text: '[image]' } } if (item.type === 'document') { toolHasMedia = true return { type: 'text' as const, text: '[document]' } } return item }) if (toolHasMedia) { hasMediaBlock = true return [{ ...block, content: newToolContent }] } } return [block] })
if (!hasMediaBlock) { return message }
return { ...message, message: { ...message.message, content: newContent, }, } as typeof message })}
/** * Strip attachment types that are re-injected post-compaction anyway. * skill_discovery/skill_listing are re-surfaced by resetSentSkillNames() * + the next turn's discovery signal, so feeding them to the summarizer * wastes tokens and pollutes the summary with stale skill suggestions. * * No-op when EXPERIMENTAL_SKILL_SEARCH is off (the attachment types * don't exist on external builds). */export function stripReinjectedAttachments(messages: Message[]): Message[] { if (feature('EXPERIMENTAL_SKILL_SEARCH')) { return messages.filter( m => !( m.type === 'attachment' && (m.attachment.type === 'skill_discovery' || m.attachment.type === 'skill_listing') ), ) } return messages}
export const ERROR_MESSAGE_NOT_ENOUGH_MESSAGES = 'Not enough messages to compact.'const MAX_PTL_RETRIES = 3const PTL_RETRY_MARKER = '[earlier conversation truncated for compaction retry]'
/** * Drops the oldest API-round groups from messages until tokenGap is covered. * Falls back to dropping 20% of groups when the gap is unparseable (some * Vertex/Bedrock error formats). Returns null when nothing can be dropped * without leaving an empty summarize set. * * This is the last-resort escape hatch for CC-1180 — when the compact request * itself hits prompt-too-long, the user is otherwise stuck. Dropping the * oldest context is lossy but unblocks them. The reactive-compact path * (compactMessages.ts) has the proper retry loop that peels from the tail; * this helper is the dumb-but-safe fallback for the proactive/manual path * that wasn't migrated in bfdb472f's unification. */export function truncateHeadForPTLRetry( messages: Message[], ptlResponse: AssistantMessage,): Message[] | null { // Strip our own synthetic marker from a previous retry before grouping. // Otherwise it becomes its own group 0 and the 20% fallback stalls // (drops only the marker, re-adds it, zero progress on retry 2+). const input = messages[0]?.type === 'user' && messages[0].isMeta && messages[0].message.content === PTL_RETRY_MARKER ? messages.slice(1) : messages
const groups = groupMessagesByApiRound(input) if (groups.length < 2) return null
const tokenGap = getPromptTooLongTokenGap(ptlResponse) let dropCount: number if (tokenGap !== undefined) { let acc = 0 dropCount = 0 for (const g of groups) { acc += roughTokenCountEstimationForMessages(g) dropCount++ if (acc >= tokenGap) break } } else { dropCount = Math.max(1, Math.floor(groups.length * 0.2)) }
// Keep at least one group so there's something to summarize. dropCount = Math.min(dropCount, groups.length - 1) if (dropCount < 1) return null
const sliced = groups.slice(dropCount).flat() // groupMessagesByApiRound puts the preamble in group 0 and starts every // subsequent group with an assistant message. Dropping group 0 leaves an // assistant-first sequence which the API rejects (first message must be // role=user). Prepend a synthetic user marker — ensureToolResultPairing // already handles any orphaned tool_results this creates. if (sliced[0]?.type === 'assistant') { return [ createUserMessage({ content: PTL_RETRY_MARKER, isMeta: true }), ...sliced, ] } return sliced}
export const ERROR_MESSAGE_PROMPT_TOO_LONG = 'Conversation too long. Press esc twice to go up a few messages and try again.'export const ERROR_MESSAGE_USER_ABORT = 'API Error: Request was aborted.'export const ERROR_MESSAGE_INCOMPLETE_RESPONSE = 'Compaction interrupted · This may be due to network issues — please try again.'
export interface CompactionResult { boundaryMarker: SystemMessage summaryMessages: UserMessage[] attachments: AttachmentMessage[] hookResults: HookResultMessage[] messagesToKeep?: Message[] userDisplayMessage?: string preCompactTokenCount?: number postCompactTokenCount?: number truePostCompactTokenCount?: number compactionUsage?: ReturnType<typeof getTokenUsage>}
/** * Diagnosis context passed from autoCompactIfNeeded into compactConversation. * Lets the tengu_compact event disambiguate same-chain loops (H2) from * cross-agent (H1/H5) and manual-vs-auto (H3) compactions without joins. */export type RecompactionInfo = { isRecompactionInChain: boolean turnsSincePreviousCompact: number previousCompactTurnId?: string autoCompactThreshold: number querySource?: QuerySource}
/** * Build the base post-compact messages array from a CompactionResult. * This ensures consistent ordering across all compaction paths. * Order: boundaryMarker, summaryMessages, messagesToKeep, attachments, hookResults */export function buildPostCompactMessages(result: CompactionResult): Message[] { return [ result.boundaryMarker, ...result.summaryMessages, ...(result.messagesToKeep ?? []), ...result.attachments, ...result.hookResults, ]}
/** * Annotate a compact boundary with relink metadata for messagesToKeep. * Preserved messages keep their original parentUuids on disk (dedup-skipped); * the loader uses this to patch head→anchor and anchor's-other-children→tail. * * `anchorUuid` = what sits immediately before keep[0] in the desired chain: * - suffix-preserving (reactive/session-memory): last summary message * - prefix-preserving (partial compact): the boundary itself */export function annotateBoundaryWithPreservedSegment( boundary: SystemCompactBoundaryMessage, anchorUuid: UUID, messagesToKeep: readonly Message[] | undefined,): SystemCompactBoundaryMessage { const keep = messagesToKeep ?? [] if (keep.length === 0) return boundary return { ...boundary, compactMetadata: { ...boundary.compactMetadata, preservedSegment: { headUuid: keep[0]!.uuid, anchorUuid, tailUuid: keep.at(-1)!.uuid, }, }, }}
/** * Merges user-supplied custom instructions with hook-provided instructions. * User instructions come first; hook instructions are appended. * Empty strings normalize to undefined. */export function mergeHookInstructions( userInstructions: string | undefined, hookInstructions: string | undefined,): string | undefined { if (!hookInstructions) return userInstructions || undefined if (!userInstructions) return hookInstructions return `${userInstructions}\n\n${hookInstructions}`}
/** * Creates a compact version of a conversation by summarizing older messages * and preserving recent conversation history. */export async function compactConversation( messages: Message[], context: ToolUseContext, cacheSafeParams: CacheSafeParams, suppressFollowUpQuestions: boolean, customInstructions?: string, isAutoCompact: boolean = false, recompactionInfo?: RecompactionInfo,): Promise<CompactionResult> { try { if (messages.length === 0) { throw new Error(ERROR_MESSAGE_NOT_ENOUGH_MESSAGES) }
const preCompactTokenCount = tokenCountWithEstimation(messages)
const appState = context.getAppState() void logPermissionContextForAnts(appState.toolPermissionContext, 'summary')
context.onCompactProgress?.({ type: 'hooks_start', hookType: 'pre_compact', })
// Execute PreCompact hooks context.setSDKStatus?.('compacting') const hookResult = await executePreCompactHooks( { trigger: isAutoCompact ? 'auto' : 'manual', customInstructions: customInstructions ?? null, }, context.abortController.signal, ) customInstructions = mergeHookInstructions( customInstructions, hookResult.newCustomInstructions, ) const userDisplayMessage = hookResult.userDisplayMessage
// Show requesting mode with up arrow and custom message context.setStreamMode?.('requesting') context.setResponseLength?.(() => 0) context.onCompactProgress?.({ type: 'compact_start' })
// 3P default: true — forked-agent path reuses main conversation's prompt cache. // Experiment (Jan 2026) confirmed: false path is 98% cache miss, costs ~0.76% of // fleet cache_creation (~38B tok/day), concentrated in ephemeral envs (CCR/GHA/SDK) // with cold GB cache and 3P providers where GB is disabled. GB gate kept as kill-switch. const promptCacheSharingEnabled = getFeatureValue_CACHED_MAY_BE_STALE( 'tengu_compact_cache_prefix', true, )
const compactPrompt = getCompactPrompt(customInstructions) const summaryRequest = createUserMessage({ content: compactPrompt, })
let messagesToSummarize = messages let retryCacheSafeParams = cacheSafeParams let summaryResponse: AssistantMessage let summary: string | null let ptlAttempts = 0 for (;;) { summaryResponse = await streamCompactSummary({ messages: messagesToSummarize, summaryRequest, appState, context, preCompactTokenCount, cacheSafeParams: retryCacheSafeParams, }) summary = getAssistantMessageText(summaryResponse) if (!summary?.startsWith(PROMPT_TOO_LONG_ERROR_MESSAGE)) break
// CC-1180: compact request itself hit prompt-too-long. Truncate the // oldest API-round groups and retry rather than leaving the user stuck. ptlAttempts++ const truncated = ptlAttempts <= MAX_PTL_RETRIES ? truncateHeadForPTLRetry(messagesToSummarize, summaryResponse) : null if (!truncated) { logEvent('tengu_compact_failed', { reason: 'prompt_too_long' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, preCompactTokenCount, promptCacheSharingEnabled, ptlAttempts, }) throw new Error(ERROR_MESSAGE_PROMPT_TOO_LONG) } logEvent('tengu_compact_ptl_retry', { attempt: ptlAttempts, droppedMessages: messagesToSummarize.length - truncated.length, remainingMessages: truncated.length, }) messagesToSummarize = truncated // The forked-agent path reads from cacheSafeParams.forkContextMessages, // not the messages param — thread the truncated set through both paths. retryCacheSafeParams = { ...retryCacheSafeParams, forkContextMessages: truncated, } }
if (!summary) { logForDebugging( `Compact failed: no summary text in response. Response: ${jsonStringify(summaryResponse)}`, { level: 'error' }, ) logEvent('tengu_compact_failed', { reason: 'no_summary' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, preCompactTokenCount, promptCacheSharingEnabled, }) throw new Error( `Failed to generate conversation summary - response did not contain valid text content`, ) } else if (startsWithApiErrorPrefix(summary)) { logEvent('tengu_compact_failed', { reason: 'api_error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, preCompactTokenCount, promptCacheSharingEnabled, }) throw new Error(summary) }
// Store the current file state before clearing const preCompactReadFileState = cacheToObject(context.readFileState)
// Clear the cache context.readFileState.clear() context.loadedNestedMemoryPaths?.clear()
// Intentionally NOT resetting sentSkillNames: re-injecting the full // skill_listing (~4K tokens) post-compact is pure cache_creation with // marginal benefit. The model still has SkillTool in its schema and // invoked_skills attachment (below) preserves used-skill content. Ants // with EXPERIMENTAL_SKILL_SEARCH already skip re-injection via the // early-return in getSkillListingAttachments.
// Run async attachment generation in parallel const [fileAttachments, asyncAgentAttachments] = await Promise.all([ createPostCompactFileAttachments( preCompactReadFileState, context, POST_COMPACT_MAX_FILES_TO_RESTORE, ), createAsyncAgentAttachmentsIfNeeded(context), ])
const postCompactFileAttachments: AttachmentMessage[] = [ ...fileAttachments, ...asyncAgentAttachments, ] const planAttachment = createPlanAttachmentIfNeeded(context.agentId) if (planAttachment) { postCompactFileAttachments.push(planAttachment) }
// Add plan mode instructions if currently in plan mode, so the model // continues operating in plan mode after compaction const planModeAttachment = await createPlanModeAttachmentIfNeeded(context) if (planModeAttachment) { postCompactFileAttachments.push(planModeAttachment) }
// Add skill attachment if skills were invoked in this session const skillAttachment = createSkillAttachmentIfNeeded(context.agentId) if (skillAttachment) { postCompactFileAttachments.push(skillAttachment) }
// Compaction ate prior delta attachments. Re-announce from the current // state so the model has tool/instruction context on the first // post-compact turn. Empty message history → diff against nothing → // announces the full set. for (const att of getDeferredToolsDeltaAttachment( context.options.tools, context.options.mainLoopModel, [], { callSite: 'compact_full' }, )) { postCompactFileAttachments.push(createAttachmentMessage(att)) } for (const att of getAgentListingDeltaAttachment(context, [])) { postCompactFileAttachments.push(createAttachmentMessage(att)) } for (const att of getMcpInstructionsDeltaAttachment( context.options.mcpClients, context.options.tools, context.options.mainLoopModel, [], )) { postCompactFileAttachments.push(createAttachmentMessage(att)) }
context.onCompactProgress?.({ type: 'hooks_start', hookType: 'session_start', }) // Execute SessionStart hooks after successful compaction const hookMessages = await processSessionStartHooks('compact', { model: context.options.mainLoopModel, })
// Create the compact boundary marker and summary messages before the // event so we can compute the true resulting-context size. const boundaryMarker = createCompactBoundaryMessage( isAutoCompact ? 'auto' : 'manual', preCompactTokenCount ?? 0, messages.at(-1)?.uuid, ) // Carry loaded-tool state — the summary doesn't preserve tool_reference // blocks, so the post-compact schema filter needs this to keep sending // already-loaded deferred tool schemas to the API. const preCompactDiscovered = extractDiscoveredToolNames(messages) if (preCompactDiscovered.size > 0) { boundaryMarker.compactMetadata.preCompactDiscoveredTools = [ ...preCompactDiscovered, ].sort() }
const transcriptPath = getTranscriptPath() const summaryMessages: UserMessage[] = [ createUserMessage({ content: getCompactUserSummaryMessage( summary, suppressFollowUpQuestions, transcriptPath, ), isCompactSummary: true, isVisibleInTranscriptOnly: true, }), ]
// Previously "postCompactTokenCount" — renamed because this is the // compact API call's total usage (input_tokens ≈ preCompactTokenCount), // NOT the size of the resulting context. Kept for event-field continuity. const compactionCallTotalTokens = tokenCountFromLastAPIResponse([ summaryResponse, ])
// Message-payload estimate of the resulting context. The next iteration's // shouldAutoCompact will see this PLUS ~20-40K for system prompt + tools + // userContext (via API usage.input_tokens). So `willRetriggerNextTurn: true` // is a strong signal; `false` may still retrigger when this is close to threshold. const truePostCompactTokenCount = roughTokenCountEstimationForMessages([ boundaryMarker, ...summaryMessages, ...postCompactFileAttachments, ...hookMessages, ])
// Extract compaction API usage metrics const compactionUsage = getTokenUsage(summaryResponse)
const querySourceForEvent = recompactionInfo?.querySource ?? context.options.querySource ?? 'unknown'
logEvent('tengu_compact', { preCompactTokenCount, // Kept for continuity — semantically the compact API call's total usage postCompactTokenCount: compactionCallTotalTokens, truePostCompactTokenCount, autoCompactThreshold: recompactionInfo?.autoCompactThreshold ?? -1, willRetriggerNextTurn: recompactionInfo !== undefined && truePostCompactTokenCount >= recompactionInfo.autoCompactThreshold, isAutoCompact, querySource: querySourceForEvent as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryChainId: (context.queryTracking?.chainId ?? '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: context.queryTracking?.depth ?? -1, isRecompactionInChain: recompactionInfo?.isRecompactionInChain ?? false, turnsSincePreviousCompact: recompactionInfo?.turnsSincePreviousCompact ?? -1, previousCompactTurnId: (recompactionInfo?.previousCompactTurnId ?? '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, compactionInputTokens: compactionUsage?.input_tokens, compactionOutputTokens: compactionUsage?.output_tokens, compactionCacheReadTokens: compactionUsage?.cache_read_input_tokens ?? 0, compactionCacheCreationTokens: compactionUsage?.cache_creation_input_tokens ?? 0, compactionTotalTokens: compactionUsage ? compactionUsage.input_tokens + (compactionUsage.cache_creation_input_tokens ?? 0) + (compactionUsage.cache_read_input_tokens ?? 0) + compactionUsage.output_tokens : 0, promptCacheSharingEnabled, // analyzeContext walks every content block (~11ms on a 4.5K-message // session) purely for this telemetry breakdown. Computed here, past // the compaction-API await, so the sync walk doesn't starve the // render loop before compaction even starts. Same deferral pattern // as reactiveCompact.ts. ...(() => { try { return tokenStatsToStatsigMetrics(analyzeContext(messages)) } catch (error) { logError(error as Error) return {} } })(), })
// Reset cache read baseline so the post-compact drop isn't flagged as a break if (feature('PROMPT_CACHE_BREAK_DETECTION')) { notifyCompaction( context.options.querySource ?? 'compact', context.agentId, ) } markPostCompaction()
// Re-append session metadata (custom title, tag) so it stays within // the 16KB tail window that readLiteMetadata reads for --resume display. // Without this, enough post-compaction messages push the metadata entry // out of the window, causing --resume to show the auto-generated title // instead of the user-set session name. reAppendSessionMetadata()
// Write a reduced transcript segment for the pre-compaction messages // (assistant mode only). Fire-and-forget — errors are logged internally. if (feature('KAIROS')) { void sessionTranscriptModule?.writeSessionTranscriptSegment(messages) }
context.onCompactProgress?.({ type: 'hooks_start', hookType: 'post_compact', }) const postCompactHookResult = await executePostCompactHooks( { trigger: isAutoCompact ? 'auto' : 'manual', compactSummary: summary, }, context.abortController.signal, )
const combinedUserDisplayMessage = [ userDisplayMessage, postCompactHookResult.userDisplayMessage, ] .filter(Boolean) .join('\n')
return { boundaryMarker, summaryMessages, attachments: postCompactFileAttachments, hookResults: hookMessages, userDisplayMessage: combinedUserDisplayMessage || undefined, preCompactTokenCount, postCompactTokenCount: compactionCallTotalTokens, truePostCompactTokenCount, compactionUsage, } } catch (error) { // Only show the error notification for manual /compact. // Auto-compact failures are retried on the next turn and the // notification is confusing when compaction eventually succeeds. if (!isAutoCompact) { addErrorNotificationIfNeeded(error, context) } throw error } finally { context.setStreamMode?.('requesting') context.setResponseLength?.(() => 0) context.onCompactProgress?.({ type: 'compact_end' }) context.setSDKStatus?.(null) }}
/** * Performs a partial compaction around the selected message index. * Direction 'from': summarizes messages after the index, keeps earlier ones. * Prompt cache for kept (earlier) messages is preserved. * Direction 'up_to': summarizes messages before the index, keeps later ones. * Prompt cache is invalidated since the summary precedes the kept messages. */export async function partialCompactConversation( allMessages: Message[], pivotIndex: number, context: ToolUseContext, cacheSafeParams: CacheSafeParams, userFeedback?: string, direction: PartialCompactDirection = 'from',): Promise<CompactionResult> { try { const messagesToSummarize = direction === 'up_to' ? allMessages.slice(0, pivotIndex) : allMessages.slice(pivotIndex) // 'up_to' must strip old compact boundaries/summaries: for 'up_to', // summary_B sits BEFORE kept, so a stale boundary_A in kept wins // findLastCompactBoundaryIndex's backward scan and drops summary_B. // 'from' keeps them: summary_B sits AFTER kept (backward scan still // works), and removing an old summary would lose its covered history. const messagesToKeep = direction === 'up_to' ? allMessages .slice(pivotIndex) .filter( m => m.type !== 'progress' && !isCompactBoundaryMessage(m) && !(m.type === 'user' && m.isCompactSummary), ) : allMessages.slice(0, pivotIndex).filter(m => m.type !== 'progress')
if (messagesToSummarize.length === 0) { throw new Error( direction === 'up_to' ? 'Nothing to summarize before the selected message.' : 'Nothing to summarize after the selected message.', ) }
const preCompactTokenCount = tokenCountWithEstimation(allMessages)
context.onCompactProgress?.({ type: 'hooks_start', hookType: 'pre_compact', })
context.setSDKStatus?.('compacting') const hookResult = await executePreCompactHooks( { trigger: 'manual', customInstructions: null, }, context.abortController.signal, )
// Merge hook instructions with user feedback let customInstructions: string | undefined if (hookResult.newCustomInstructions && userFeedback) { customInstructions = `${hookResult.newCustomInstructions}\n\nUser context: ${userFeedback}` } else if (hookResult.newCustomInstructions) { customInstructions = hookResult.newCustomInstructions } else if (userFeedback) { customInstructions = `User context: ${userFeedback}` }
context.setStreamMode?.('requesting') context.setResponseLength?.(() => 0) context.onCompactProgress?.({ type: 'compact_start' })
const compactPrompt = getPartialCompactPrompt(customInstructions, direction) const summaryRequest = createUserMessage({ content: compactPrompt, })
const failureMetadata = { preCompactTokenCount, direction: direction as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, messagesSummarized: messagesToSummarize.length, }
// 'up_to' prefix hits cache directly; 'from' sends all (tail wouldn't cache). // PTL retry breaks the cache prefix but unblocks the user (CC-1180). let apiMessages = direction === 'up_to' ? messagesToSummarize : allMessages let retryCacheSafeParams = direction === 'up_to' ? { ...cacheSafeParams, forkContextMessages: messagesToSummarize } : cacheSafeParams let summaryResponse: AssistantMessage let summary: string | null let ptlAttempts = 0 for (;;) { summaryResponse = await streamCompactSummary({ messages: apiMessages, summaryRequest, appState: context.getAppState(), context, preCompactTokenCount, cacheSafeParams: retryCacheSafeParams, }) summary = getAssistantMessageText(summaryResponse) if (!summary?.startsWith(PROMPT_TOO_LONG_ERROR_MESSAGE)) break
ptlAttempts++ const truncated = ptlAttempts <= MAX_PTL_RETRIES ? truncateHeadForPTLRetry(apiMessages, summaryResponse) : null if (!truncated) { logEvent('tengu_partial_compact_failed', { reason: 'prompt_too_long' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...failureMetadata, ptlAttempts, }) throw new Error(ERROR_MESSAGE_PROMPT_TOO_LONG) } logEvent('tengu_compact_ptl_retry', { attempt: ptlAttempts, droppedMessages: apiMessages.length - truncated.length, remainingMessages: truncated.length, path: 'partial' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) apiMessages = truncated retryCacheSafeParams = { ...retryCacheSafeParams, forkContextMessages: truncated, } } if (!summary) { logEvent('tengu_partial_compact_failed', { reason: 'no_summary' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...failureMetadata, }) throw new Error( 'Failed to generate conversation summary - response did not contain valid text content', ) } else if (startsWithApiErrorPrefix(summary)) { logEvent('tengu_partial_compact_failed', { reason: 'api_error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...failureMetadata, }) throw new Error(summary) }
// Store the current file state before clearing const preCompactReadFileState = cacheToObject(context.readFileState) context.readFileState.clear() context.loadedNestedMemoryPaths?.clear() // Intentionally NOT resetting sentSkillNames — see compactConversation() // for rationale (~4K tokens saved per compact event).
const [fileAttachments, asyncAgentAttachments] = await Promise.all([ createPostCompactFileAttachments( preCompactReadFileState, context, POST_COMPACT_MAX_FILES_TO_RESTORE, messagesToKeep, ), createAsyncAgentAttachmentsIfNeeded(context), ])
const postCompactFileAttachments: AttachmentMessage[] = [ ...fileAttachments, ...asyncAgentAttachments, ] const planAttachment = createPlanAttachmentIfNeeded(context.agentId) if (planAttachment) { postCompactFileAttachments.push(planAttachment) }
// Add plan mode instructions if currently in plan mode const planModeAttachment = await createPlanModeAttachmentIfNeeded(context) if (planModeAttachment) { postCompactFileAttachments.push(planModeAttachment) }
const skillAttachment = createSkillAttachmentIfNeeded(context.agentId) if (skillAttachment) { postCompactFileAttachments.push(skillAttachment) }
// Re-announce only what was in the summarized portion — messagesToKeep // is scanned, so anything already announced there is skipped. for (const att of getDeferredToolsDeltaAttachment( context.options.tools, context.options.mainLoopModel, messagesToKeep, { callSite: 'compact_partial' }, )) { postCompactFileAttachments.push(createAttachmentMessage(att)) } for (const att of getAgentListingDeltaAttachment(context, messagesToKeep)) { postCompactFileAttachments.push(createAttachmentMessage(att)) } for (const att of getMcpInstructionsDeltaAttachment( context.options.mcpClients, context.options.tools, context.options.mainLoopModel, messagesToKeep, )) { postCompactFileAttachments.push(createAttachmentMessage(att)) }
context.onCompactProgress?.({ type: 'hooks_start', hookType: 'session_start', }) const hookMessages = await processSessionStartHooks('compact', { model: context.options.mainLoopModel, })
const postCompactTokenCount = tokenCountFromLastAPIResponse([ summaryResponse, ]) const compactionUsage = getTokenUsage(summaryResponse)
logEvent('tengu_partial_compact', { preCompactTokenCount, postCompactTokenCount, messagesKept: messagesToKeep.length, messagesSummarized: messagesToSummarize.length, direction: direction as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, hasUserFeedback: !!userFeedback, trigger: 'message_selector' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, compactionInputTokens: compactionUsage?.input_tokens, compactionOutputTokens: compactionUsage?.output_tokens, compactionCacheReadTokens: compactionUsage?.cache_read_input_tokens ?? 0, compactionCacheCreationTokens: compactionUsage?.cache_creation_input_tokens ?? 0, })
// Progress messages aren't loggable, so forkSessionImpl would null out // a logicalParentUuid pointing at one. Both directions skip them. const lastPreCompactUuid = direction === 'up_to' ? allMessages.slice(0, pivotIndex).findLast(m => m.type !== 'progress') ?.uuid : messagesToKeep.at(-1)?.uuid const boundaryMarker = createCompactBoundaryMessage( 'manual', preCompactTokenCount ?? 0, lastPreCompactUuid, userFeedback, messagesToSummarize.length, ) // allMessages not just messagesToSummarize — set union is idempotent, // simpler than tracking which half each tool lived in. const preCompactDiscovered = extractDiscoveredToolNames(allMessages) if (preCompactDiscovered.size > 0) { boundaryMarker.compactMetadata.preCompactDiscoveredTools = [ ...preCompactDiscovered, ].sort() }
const transcriptPath = getTranscriptPath() const summaryMessages: UserMessage[] = [ createUserMessage({ content: getCompactUserSummaryMessage(summary, false, transcriptPath), isCompactSummary: true, ...(messagesToKeep.length > 0 ? { summarizeMetadata: { messagesSummarized: messagesToSummarize.length, userContext: userFeedback, direction, }, } : { isVisibleInTranscriptOnly: true as const }), }), ]
if (feature('PROMPT_CACHE_BREAK_DETECTION')) { notifyCompaction( context.options.querySource ?? 'compact', context.agentId, ) } markPostCompaction()
// Re-append session metadata (custom title, tag) so it stays within // the 16KB tail window that readLiteMetadata reads for --resume display. reAppendSessionMetadata()
if (feature('KAIROS')) { void sessionTranscriptModule?.writeSessionTranscriptSegment( messagesToSummarize, ) }
context.onCompactProgress?.({ type: 'hooks_start', hookType: 'post_compact', }) const postCompactHookResult = await executePostCompactHooks( { trigger: 'manual', compactSummary: summary, }, context.abortController.signal, )
// 'from': prefix-preserving → boundary; 'up_to': suffix → last summary const anchorUuid = direction === 'up_to' ? (summaryMessages.at(-1)?.uuid ?? boundaryMarker.uuid) : boundaryMarker.uuid return { boundaryMarker: annotateBoundaryWithPreservedSegment( boundaryMarker, anchorUuid, messagesToKeep, ), summaryMessages, messagesToKeep, attachments: postCompactFileAttachments, hookResults: hookMessages, userDisplayMessage: postCompactHookResult.userDisplayMessage, preCompactTokenCount, postCompactTokenCount, compactionUsage, } } catch (error) { addErrorNotificationIfNeeded(error, context) throw error } finally { context.setStreamMode?.('requesting') context.setResponseLength?.(() => 0) context.onCompactProgress?.({ type: 'compact_end' }) context.setSDKStatus?.(null) }}
function addErrorNotificationIfNeeded( error: unknown, context: Pick<ToolUseContext, 'addNotification'>,) { if ( !hasExactErrorMessage(error, ERROR_MESSAGE_USER_ABORT) && !hasExactErrorMessage(error, ERROR_MESSAGE_NOT_ENOUGH_MESSAGES) ) { context.addNotification?.({ key: 'error-compacting-conversation', text: 'Error compacting conversation', priority: 'immediate', color: 'error', }) }}
export function createCompactCanUseTool(): CanUseToolFn { return async () => ({ behavior: 'deny' as const, message: 'Tool use is not allowed during compaction', decisionReason: { type: 'other' as const, reason: 'compaction agent should only produce text summary', }, })}
async function streamCompactSummary({ messages, summaryRequest, appState, context, preCompactTokenCount, cacheSafeParams,}: { messages: Message[] summaryRequest: UserMessage appState: Awaited<ReturnType<ToolUseContext['getAppState']>> context: ToolUseContext preCompactTokenCount: number cacheSafeParams: CacheSafeParams}): Promise<AssistantMessage> { // When prompt cache sharing is enabled, use forked agent to reuse the // main conversation's cached prefix (system prompt, tools, context messages). // Falls back to regular streaming path on failure. // 3P default: true — see comment at the other tengu_compact_cache_prefix read above. const promptCacheSharingEnabled = getFeatureValue_CACHED_MAY_BE_STALE( 'tengu_compact_cache_prefix', true, ) // Send keep-alive signals during compaction to prevent remote session // WebSocket idle timeouts from dropping bridge connections. Compaction // API calls can take 5-10+ seconds, during which no other messages // flow through the transport — without keep-alives, the server may // close the WebSocket for inactivity. // Two signals: (1) PUT /worker heartbeat via sessionActivity, and // (2) re-emit 'compacting' status so the SDK event stream stays active // and the server doesn't consider the session stale. const activityInterval = isSessionActivityTrackingActive() ? setInterval( (statusSetter?: (status: 'compacting' | null) => void) => { sendSessionActivitySignal() statusSetter?.('compacting') }, 30_000, context.setSDKStatus, ) : undefined
try { if (promptCacheSharingEnabled) { try { // DO NOT set maxOutputTokens here. The fork piggybacks on the main thread's // prompt cache by sending identical cache-key params (system, tools, model, // messages prefix, thinking config). Setting maxOutputTokens would clamp // budget_tokens via Math.min(budget, maxOutputTokens-1) in claude.ts, // creating a thinking config mismatch that invalidates the cache. // The streaming fallback path (below) can safely set maxOutputTokensOverride // since it doesn't share cache with the main thread. const result = await runForkedAgent({ promptMessages: [summaryRequest], cacheSafeParams, canUseTool: createCompactCanUseTool(), querySource: 'compact', forkLabel: 'compact', maxTurns: 1, skipCacheWrite: true, // Pass the compact context's abortController so user Esc aborts the // fork — same signal the streaming fallback uses at // `signal: context.abortController.signal` below. overrides: { abortController: context.abortController }, }) const assistantMsg = getLastAssistantMessage(result.messages) const assistantText = assistantMsg ? getAssistantMessageText(assistantMsg) : null // Guard isApiErrorMessage: query() catches API errors (including // APIUserAbortError on ESC) and yields them as synthetic assistant // messages. Without this check, an aborted compact "succeeds" with // "Request was aborted." as the summary — the text doesn't start with // "API Error" so the caller's startsWithApiErrorPrefix guard misses it. if (assistantMsg && assistantText && !assistantMsg.isApiErrorMessage) { // Skip success logging for PTL error text — it's returned so the // caller's retry loop catches it, but it's not a successful summary. if (!assistantText.startsWith(PROMPT_TOO_LONG_ERROR_MESSAGE)) { logEvent('tengu_compact_cache_sharing_success', { preCompactTokenCount, outputTokens: result.totalUsage.output_tokens, cacheReadInputTokens: result.totalUsage.cache_read_input_tokens, cacheCreationInputTokens: result.totalUsage.cache_creation_input_tokens, cacheHitRate: result.totalUsage.cache_read_input_tokens > 0 ? result.totalUsage.cache_read_input_tokens / (result.totalUsage.cache_read_input_tokens + result.totalUsage.cache_creation_input_tokens + result.totalUsage.input_tokens) : 0, }) } return assistantMsg } logForDebugging( `Compact cache sharing: no text in response, falling back. Response: ${jsonStringify(assistantMsg)}`, { level: 'warn' }, ) logEvent('tengu_compact_cache_sharing_fallback', { reason: 'no_text_response' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, preCompactTokenCount, }) } catch (error) { logError(error) logEvent('tengu_compact_cache_sharing_fallback', { reason: 'error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, preCompactTokenCount, }) } }
// Regular streaming path (fallback when cache sharing fails or is disabled) const retryEnabled = getFeatureValue_CACHED_MAY_BE_STALE( 'tengu_compact_streaming_retry', false, ) const maxAttempts = retryEnabled ? MAX_COMPACT_STREAMING_RETRIES : 1
for (let attempt = 1; attempt <= maxAttempts; attempt++) { // Reset state for retry let hasStartedStreaming = false let response: AssistantMessage | undefined context.setResponseLength?.(() => 0)
// Check if tool search is enabled using the main loop's tools list. // context.options.tools includes MCP tools merged via useMergedTools. const useToolSearch = await isToolSearchEnabled( context.options.mainLoopModel, context.options.tools, async () => appState.toolPermissionContext, context.options.agentDefinitions.activeAgents, 'compact', )
// When tool search is enabled, include ToolSearchTool and MCP tools. They get // defer_loading: true and don't count against context - the API filters them out // of system_prompt_tools before token counting (see api/token_count_api/counting.py:188 // and api/public_api/messages/handler.py:324). // Filter MCP tools from context.options.tools (not appState.mcp.tools) so we // get the permission-filtered set from useMergedTools — same source used for // isToolSearchEnabled above and normalizeMessagesForAPI below. // Deduplicate by name to avoid API errors when MCP tools share names with built-in tools. const tools: Tool[] = useToolSearch ? uniqBy( [ FileReadTool, ToolSearchTool, ...context.options.tools.filter(t => t.isMcp), ], 'name', ) : [FileReadTool]
const streamingGen = queryModelWithStreaming({ messages: normalizeMessagesForAPI( stripImagesFromMessages( stripReinjectedAttachments([ ...getMessagesAfterCompactBoundary(messages), summaryRequest, ]), ), context.options.tools, ), systemPrompt: asSystemPrompt([ 'You are a helpful AI assistant tasked with summarizing conversations.', ]), thinkingConfig: { type: 'disabled' as const }, tools, signal: context.abortController.signal, options: { async getToolPermissionContext() { const appState = context.getAppState() return appState.toolPermissionContext }, model: context.options.mainLoopModel, toolChoice: undefined, isNonInteractiveSession: context.options.isNonInteractiveSession, hasAppendSystemPrompt: !!context.options.appendSystemPrompt, maxOutputTokensOverride: Math.min( COMPACT_MAX_OUTPUT_TOKENS, getMaxOutputTokensForModel(context.options.mainLoopModel), ), querySource: 'compact', agents: context.options.agentDefinitions.activeAgents, mcpTools: [], effortValue: appState.effortValue, }, }) const streamIter = streamingGen[Symbol.asyncIterator]() let next = await streamIter.next()
while (!next.done) { const event = next.value
if ( !hasStartedStreaming && event.type === 'stream_event' && event.event.type === 'content_block_start' && event.event.content_block.type === 'text' ) { hasStartedStreaming = true context.setStreamMode?.('responding') }
if ( event.type === 'stream_event' && event.event.type === 'content_block_delta' && event.event.delta.type === 'text_delta' ) { const charactersStreamed = event.event.delta.text.length context.setResponseLength?.(length => length + charactersStreamed) }
if (event.type === 'assistant') { response = event }
next = await streamIter.next() }
if (response) { return response }
if (attempt < maxAttempts) { logEvent('tengu_compact_streaming_retry', { attempt, preCompactTokenCount, hasStartedStreaming, }) await sleep(getRetryDelay(attempt), context.abortController.signal, { abortError: () => new APIUserAbortError(), }) continue }
logForDebugging( `Compact streaming failed after ${attempt} attempts. hasStartedStreaming=${hasStartedStreaming}`, { level: 'error' }, ) logEvent('tengu_compact_failed', { reason: 'no_streaming_response' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, preCompactTokenCount, hasStartedStreaming, retryEnabled, attempts: attempt, promptCacheSharingEnabled, }) throw new Error(ERROR_MESSAGE_INCOMPLETE_RESPONSE) }
// This should never be reached due to the throw above, but TypeScript needs it throw new Error(ERROR_MESSAGE_INCOMPLETE_RESPONSE) } finally { clearInterval(activityInterval) }}
/** * Creates attachment messages for recently accessed files to restore them after compaction. * This prevents the model from having to re-read files that were recently accessed. * Re-reads files using FileReadTool to get fresh content with proper validation. * Files are selected based on recency, but constrained by both file count and token budget limits. * * Files already present as Read tool results in preservedMessages are skipped — * re-injecting identical content the model can already see in the preserved tail * is pure waste (up to 25K tok/compact). Mirrors the diff-against-preserved * pattern that getDeferredToolsDeltaAttachment uses at the same call sites. * * @param readFileState The current file state tracking recently read files * @param toolUseContext The tool use context for calling FileReadTool * @param maxFiles Maximum number of files to restore (default: 5) * @param preservedMessages Messages kept post-compact; Read results here are skipped * @returns Array of attachment messages for the most recently accessed files that fit within token budget */export async function createPostCompactFileAttachments( readFileState: Record<string, { content: string; timestamp: number }>, toolUseContext: ToolUseContext, maxFiles: number, preservedMessages: Message[] = [],): Promise<AttachmentMessage[]> { const preservedReadPaths = collectReadToolFilePaths(preservedMessages) const recentFiles = Object.entries(readFileState) .map(([filename, state]) => ({ filename, ...state })) .filter( file => !shouldExcludeFromPostCompactRestore( file.filename, toolUseContext.agentId, ) && !preservedReadPaths.has(expandPath(file.filename)), ) .sort((a, b) => b.timestamp - a.timestamp) .slice(0, maxFiles)
const results = await Promise.all( recentFiles.map(async file => { const attachment = await generateFileAttachment( file.filename, { ...toolUseContext, fileReadingLimits: { maxTokens: POST_COMPACT_MAX_TOKENS_PER_FILE, }, }, 'tengu_post_compact_file_restore_success', 'tengu_post_compact_file_restore_error', 'compact', ) return attachment ? createAttachmentMessage(attachment) : null }), )
let usedTokens = 0 return results.filter((result): result is AttachmentMessage => { if (result === null) { return false } const attachmentTokens = roughTokenCountEstimation(jsonStringify(result)) if (usedTokens + attachmentTokens <= POST_COMPACT_TOKEN_BUDGET) { usedTokens += attachmentTokens return true } return false })}
/** * Creates a plan file attachment if a plan file exists for the current session. * This ensures the plan is preserved after compaction. */export function createPlanAttachmentIfNeeded( agentId?: AgentId,): AttachmentMessage | null { const planContent = getPlan(agentId)
if (!planContent) { return null }
const planFilePath = getPlanFilePath(agentId)
return createAttachmentMessage({ type: 'plan_file_reference', planFilePath, planContent, })}
/** * Creates an attachment for invoked skills to preserve their content across compaction. * Only includes skills scoped to the given agent (or main session when agentId is null/undefined). * This ensures skill guidelines remain available after the conversation is summarized * without leaking skills from other agent contexts. */export function createSkillAttachmentIfNeeded( agentId?: string,): AttachmentMessage | null { const invokedSkills = getInvokedSkillsForAgent(agentId)
if (invokedSkills.size === 0) { return null }
// Sorted most-recent-first so budget pressure drops the least-relevant skills. // Per-skill truncation keeps the head of each file (where setup/usage // instructions typically live) rather than dropping whole skills. let usedTokens = 0 const skills = Array.from(invokedSkills.values()) .sort((a, b) => b.invokedAt - a.invokedAt) .map(skill => ({ name: skill.skillName, path: skill.skillPath, content: truncateToTokens( skill.content, POST_COMPACT_MAX_TOKENS_PER_SKILL, ), })) .filter(skill => { const tokens = roughTokenCountEstimation(skill.content) if (usedTokens + tokens > POST_COMPACT_SKILLS_TOKEN_BUDGET) { return false } usedTokens += tokens return true })
if (skills.length === 0) { return null }
return createAttachmentMessage({ type: 'invoked_skills', skills, })}
/** * Creates a plan_mode attachment if the user is currently in plan mode. * This ensures the model continues to operate in plan mode after compaction * (otherwise it would lose the plan mode instructions since those are * normally only injected on tool-use turns via getAttachmentMessages). */export async function createPlanModeAttachmentIfNeeded( context: ToolUseContext,): Promise<AttachmentMessage | null> { const appState = context.getAppState() if (appState.toolPermissionContext.mode !== 'plan') { return null }
const planFilePath = getPlanFilePath(context.agentId) const planExists = getPlan(context.agentId) !== null
return createAttachmentMessage({ type: 'plan_mode', reminderType: 'full', isSubAgent: !!context.agentId, planFilePath, planExists, })}
/** * Creates attachments for async agents so the model knows about them after * compaction. Covers both agents still running in the background (so the model * doesn't spawn a duplicate) and agents that have finished but whose results * haven't been retrieved yet. */export async function createAsyncAgentAttachmentsIfNeeded( context: ToolUseContext,): Promise<AttachmentMessage[]> { const appState = context.getAppState() const asyncAgents = Object.values(appState.tasks).filter( (task): task is LocalAgentTaskState => task.type === 'local_agent', )
return asyncAgents.flatMap(agent => { if ( agent.retrieved || agent.status === 'pending' || agent.agentId === context.agentId ) { return [] } return [ createAttachmentMessage({ type: 'task_status', taskId: agent.agentId, taskType: 'local_agent', description: agent.description, status: agent.status, deltaSummary: agent.status === 'running' ? (agent.progress?.summary ?? null) : (agent.error ?? null), outputFilePath: getTaskOutputPath(agent.agentId), }), ] })}
/** * Scan messages for Read tool_use blocks and collect their file_path inputs * (normalized via expandPath). Used to dedup post-compact file restoration * against what's already visible in the preserved tail. * * Skips Reads whose tool_result is a dedup stub — the stub points at an * earlier full Read that may have been compacted away, so we want * createPostCompactFileAttachments to re-inject the real content. */function collectReadToolFilePaths(messages: Message[]): Set<string> { const stubIds = new Set<string>() for (const message of messages) { if (message.type !== 'user' || !Array.isArray(message.message.content)) { continue } for (const block of message.message.content) { if ( block.type === 'tool_result' && typeof block.content === 'string' && block.content.startsWith(FILE_UNCHANGED_STUB) ) { stubIds.add(block.tool_use_id) } } }
const paths = new Set<string>() for (const message of messages) { if ( message.type !== 'assistant' || !Array.isArray(message.message.content) ) { continue } for (const block of message.message.content) { if ( block.type !== 'tool_use' || block.name !== FILE_READ_TOOL_NAME || stubIds.has(block.id) ) { continue } const input = block.input if ( input && typeof input === 'object' && 'file_path' in input && typeof input.file_path === 'string' ) { paths.add(expandPath(input.file_path)) } } } return paths}
const SKILL_TRUNCATION_MARKER = '\n\n[... skill content truncated for compaction; use Read on the skill path if you need the full text]'
/** * Truncate content to roughly maxTokens, keeping the head. roughTokenCountEstimation * uses ~4 chars/token (its default bytesPerToken), so char budget = maxTokens * 4 * minus the marker so the result stays within budget. Marker tells the model it * can Read the full file if needed. */function truncateToTokens(content: string, maxTokens: number): string { if (roughTokenCountEstimation(content) <= maxTokens) { return content } const charBudget = maxTokens * 4 - SKILL_TRUNCATION_MARKER.length return content.slice(0, charBudget) + SKILL_TRUNCATION_MARKER}
function shouldExcludeFromPostCompactRestore( filename: string, agentId?: AgentId,): boolean { const normalizedFilename = expandPath(filename) // Exclude plan files try { const planFilePath = expandPath(getPlanFilePath(agentId)) if (normalizedFilename === planFilePath) { return true } } catch { // If we can't get plan file path, continue with other checks }
// Exclude all types of claude.md files // TODO: Refactor to use isMemoryFilePath() from claudemd.ts for consistency // and to also match child directory memory files (.claude/rules/*.md, etc.) try { const normalizedMemoryPaths = new Set( MEMORY_TYPE_VALUES.map(type => expandPath(getMemoryPath(type))), )
if (normalizedMemoryPaths.has(normalizedFilename)) { return true } } catch { // If we can't get memory paths, continue }
return false}