| import type { IDisposable } from '#/_base/di/lifecycle'; |
| import { Service } from "#/_base/di/service"; |
| import { LifecycleScope } from '#/app/scopes'; |
| import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; |
| import { defineState } from '#/state/state'; |
| import { estimateTokensForMessage } from "#/llm-adapter/contract/tokens"; |
| import { buildCompactionSummaryText, isRealUserInput } from '#/agent/contextMemory/compactionHandoff'; |
| import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; |
| import type { ContextMessage } from '#/agent/contextMemory/types'; |
| import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; |
| import { IAgentLLMRequesterService, type AgentLLMRequestFinish } from '#/agent/llmRequester/llmRequester'; |
| import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; |
| import { retryBackoffDelay, sleepForRetry } from '#/_base/utils/retry'; |
| import { runWithCredentialRecovery } from '#/llm-adapter/model/credential-recovery'; |
| import { IAgentLoopService, type LoopErrorContext } from '#/agent/loop/loop'; |
| import { TurnStarted } from '#/agent/loop/turnEvents'; |
| import { TurnEnded } from '#/agent/loop/turnOps'; |
| import { isAbortError } from '#/_base/utils/abort'; |
| import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile'; |
| import { |
| agentContextOfScope, |
| IAgentScopeContext, |
| } from '#/agent/scopeContext/scopeContext'; |
| import { IAgentStateService } from '#/agent/state/agentState'; |
| import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; |
| import { stripDynamicToolContext } from '#/agent/toolSelect/dynamicTools'; |
| import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; |
| import { IAgentTodoService } from '#/features/todo/todoService'; |
| import { renderTodoList } from '#/features/todo/todoItem'; |
| import { onUnexpectedError } from '#/_base/errors/unexpectedError'; |
| import type { WireLineRange } from '#/wire/record'; |
| import { IWireService } from '#/wire/wire'; |
| import { |
| APIContextOverflowError, |
| APIEmptyResponseError, |
| APIStatusError, |
| isRetryableGenerateError, |
| } from '#/llm-adapter/contract/errors'; |
| import { createUserMessage, type Message } from '#/llm-adapter/contract/message'; |
| import type { ToolDescription as Tool } from '#human/llm/message'; |
| import { inputTotal, type TokenUsage } from '#human/llm/usage'; |
| import { IEventBus } from '#/app/event/eventBus'; |
| import type { CompactionFailedEvent, CompactionFinishedEvent } from '#/app/telemetry/events'; |
| import { ITelemetryService } from '#/app/telemetry/telemetry'; |
| import { ErrorCodes, Error2, isCodedError, isError2, toKimiErrorPayload, unwrapErrorCause } from "#/errors"; |
| import { AgentErrorEvent } from '#/agent/mcp/mcpEvents'; |
| import { IEventDispatcher } from '#/state/eventDispatcher'; |
| import { renderCompactionInstruction } from './compactionInstruction'; |
| import { renderContextRecoveryPointer } from './contextRecovery'; |
| import { |
| IAgentFullCompactionService, |
| type FullCompactionInput, |
| type FullCompactionTask, |
| } from './fullCompaction'; |
| import { |
| RuntimeCompactionStrategy, |
| type CompactionStrategy, |
| } from './strategy'; |
| import { |
| CompactionBlocked, |
| CompactionCancelled, |
| CompactionCompleted, |
| fullCompactionKey, |
| fullCompactionWireRangesKey, |
| FullCompactionBegin, |
| FullCompactionCancel, |
| FullCompactionComplete, |
| } from './compactionOps'; |
| import { |
| type CompactionBeginData, |
| type CompactionResult, |
| } from './types'; |
| import { Emitter, type Event } from '#/_base/event'; |
| import { OrderedHookSlot } from '#/hooks'; |
|
|
| export const MAX_COMPACTION_RETRY_ATTEMPTS = 5; |
| const DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS = 128 * 1024; |
| const OVERFLOW_CONTEXT_SAFETY_RATIO = 0.85; |
| const OVERFLOW_STATUS_RECOVERY_RATIO = 0.5; |
| const MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS = 3; |
| const COMPACTION_OVERFLOW_SHRINK_RATIOS = [0.7, 0.5, 0.35] as const; |
| const EMPTY_TOOL_PARAMETERS: Record<string, unknown> = { |
| type: 'object', |
| properties: {}, |
| }; |
|
|
| type CompactionTelemetryProperties = Pick< |
| CompactionFinishedEvent, |
| 'input_tokens' | 'output_tokens' | 'input_cache_read' | 'input_cache_creation' |
| >; |
|
|
| interface ActiveCompaction extends FullCompactionTask { |
| readonly originTurnId?: number; |
| readonly quiescence?: IDisposable; |
| trace?: LLMRequestTrace; |
| blockedByTurn: boolean; |
| } |
|
|
| interface CompactionAttemptResult { |
| readonly summary: string; |
| readonly usage: TokenUsage | null; |
| readonly traceId?: string; |
| } |
|
|
| class CompactionTruncatedError extends Error { |
| constructor() { |
| super('Compaction response was truncated before producing a complete summary.'); |
| this.name = 'CompactionTruncatedError'; |
| } |
| } |
|
|
| export const fullCompactionCompactionCountInTurnKey = defineState<number>( |
| 'fullCompaction.compactionCountInTurn', |
| () => 0, |
| ); |
| export const fullCompactionObservedMaxContextTokensByModelKey = defineState<Map<string, number>>( |
| 'fullCompaction.observedMaxContextTokensByModel', |
| () => new Map(), |
| ); |
| export const fullCompactionLastCompactedTokenCountKey = defineState<number | null>( |
| 'fullCompaction.lastCompactedTokenCount', |
| () => null, |
| ); |
| export const fullCompactionConsecutiveOverflowCompactionsKey = defineState<number>( |
| 'fullCompaction.consecutiveOverflowCompactions', |
| () => 0, |
| ); |
| export const fullCompactionActiveTurnIdKey = defineState<number | undefined>( |
| 'fullCompaction.activeTurnId', |
| () => undefined as number | undefined, |
| ); |
|
|
| export class AgentFullCompactionService extends Service implements IAgentFullCompactionService { |
| declare readonly _serviceBrand: undefined; |
| readonly hooks: IAgentFullCompactionService['hooks'] = { |
| onWillCompact: new OrderedHookSlot<FullCompactionTask>(), |
| }; |
| private readonly _onDidFinishCompaction = this._register(new Emitter<FullCompactionTask>()); |
| readonly onDidFinishCompaction: Event<FullCompactionTask> = this._onDidFinishCompaction.event; |
|
|
| private readonly strategy: CompactionStrategy; |
| private _compacting: ActiveCompaction | null = null; |
|
|
| constructor( |
| @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, |
| @ISessionTokenCountingService private readonly tokenCounting: ISessionTokenCountingService, |
| @IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService, |
| @IAgentProfileService private readonly profile: IAgentProfileService, |
| @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, |
| @IAgentToolSelectService private readonly toolSelect: IAgentToolSelectService, |
| @IAgentScopeContext private readonly agent: IAgentScopeContext, |
| @IAgentTodoService private readonly todo: IAgentTodoService, |
| @ITelemetryService private readonly telemetry: ITelemetryService, |
| @IEventDispatcher private readonly dispatcher: IEventDispatcher, |
| @IEventBus private readonly eventBus: IEventBus, |
| @IAgentLoopService private readonly loopService: IAgentLoopService, |
| @IAgentStateService private readonly states: IAgentStateService, |
| @IWireService private readonly wire: IWireService, |
| ) { |
| super(); |
| this.states.contributeState(fullCompactionKey); |
| this.states.contributeState(fullCompactionWireRangesKey); |
| this.states.contributeState(fullCompactionCompactionCountInTurnKey); |
| this.states.contributeState(fullCompactionObservedMaxContextTokensByModelKey); |
| this.states.contributeState(fullCompactionLastCompactedTokenCountKey); |
| this.states.contributeState(fullCompactionConsecutiveOverflowCompactionsKey); |
| this.states.contributeState(fullCompactionActiveTurnIdKey); |
| this.strategy = new RuntimeCompactionStrategy( |
| () => this.resolveModelContextWithEffectiveMax(), |
| (message) => this.tokenCounting.estimateMessage(message), |
| ); |
| this._register( |
| this.dispatcher.hooks.onDidRestore.register('full-compaction', async (_ctx, next) => { |
| this.normalizeAfterReplay(); |
| await next(); |
| }), |
| ); |
| this._register( |
| this.eventBus.subscribe(TurnStarted, () => this.resetForTurn()), |
| ); |
| this._register( |
| this.eventBus.subscribe(TurnEnded, () => { |
| this.activeTurnId = undefined; |
| }), |
| ); |
| this._register( |
| this.loopService.hooks.onWillBeginStep.register('full-compaction', async (ctx, next) => { |
| await this.beforeStep(ctx.signal, ctx.turnId); |
| await next(); |
| }), |
| ); |
| this._register( |
| this.loopService.hooks.onDidFinishStep.register('full-compaction', async (_ctx, next) => { |
| await this.afterStep(); |
| await next(); |
| }), |
| ); |
| this._register( |
| this.loopService.registerLoopErrorHandler({ |
| id: 'full-compaction', |
| match: (context) => this.shouldRecoverFromContextOverflow(context.error), |
| handle: (context) => this.recoverFromContextOverflow(context), |
| }), |
| ); |
| } |
|
|
| private get compactionCountInTurn(): number { |
| return this.states.get(fullCompactionCompactionCountInTurnKey); |
| } |
|
|
| private set compactionCountInTurn(value: number) { |
| this.states.set(fullCompactionCompactionCountInTurnKey, value); |
| } |
|
|
| private get observedMaxContextTokensByModel(): Map<string, number> { |
| return this.states.get(fullCompactionObservedMaxContextTokensByModelKey); |
| } |
|
|
| private get lastCompactedTokenCount(): number | null { |
| return this.states.get(fullCompactionLastCompactedTokenCountKey); |
| } |
|
|
| private set lastCompactedTokenCount(value: number | null) { |
| this.states.set(fullCompactionLastCompactedTokenCountKey, value); |
| } |
|
|
| private get consecutiveOverflowCompactions(): number { |
| return this.states.get(fullCompactionConsecutiveOverflowCompactionsKey); |
| } |
|
|
| private set consecutiveOverflowCompactions(value: number) { |
| this.states.set(fullCompactionConsecutiveOverflowCompactionsKey, value); |
| } |
|
|
| private get activeTurnId(): number | undefined { |
| return this.states.get(fullCompactionActiveTurnIdKey); |
| } |
|
|
| private set activeTurnId(value: number | undefined) { |
| this.states.set(fullCompactionActiveTurnIdKey, value); |
| } |
|
|
| get compacting(): FullCompactionTask | null { |
| return this._compacting; |
| } |
|
|
| cancel(): void { |
| const active = this._compacting; |
| if (active !== null) { |
| this.telemetry.track2('cancel', { |
| from: 'compacting', |
| trace_id: active.traceId, |
| }); |
| } |
| active?.abortController.abort(); |
| } |
|
|
| private getEffectiveMaxContextTokens(): number { |
| const capability = this.profile.data().modelCapabilities; |
| const configured = capability.max_input_tokens ?? capability.max_context_tokens; |
| const modelAlias = this.profile.data().modelAlias; |
| const observed = |
| modelAlias === undefined ? undefined : this.observedMaxContextTokensByModel.get(modelAlias); |
| if (observed === undefined) return configured; |
| if (configured <= 0) return observed; |
| return Math.min(configured, observed); |
| } |
|
|
| private resolveModelContextWithEffectiveMax(): ProfileModelContext { |
| const resolved = this.profile.resolveModelContext(); |
| const effectiveMax = this.getEffectiveMaxContextTokens(); |
| return { |
| ...resolved, |
| modelCapabilities: { |
| ...resolved.modelCapabilities, |
| max_context_tokens: effectiveMax, |
| max_input_tokens: effectiveMax, |
| }, |
| }; |
| } |
|
|
| private currentRequestTokens(): number { |
| return this.requestTokens(this.context.get()); |
| } |
|
|
| private requestTokens(messages: readonly Message[]): number { |
| return this.tokenCounting.requestSize({ |
| systemPrompt: this.profile.getSystemPrompt(), |
| tools: this.defaultTools().filter((tool) => tool.deferred !== true), |
| messages, |
| }); |
| } |
|
|
| private defaultTools(): readonly Tool[] { |
| return this.toolSelect |
| .shapeTools(this.toolRegistry.list()) |
| .map((tool) => ({ |
| name: tool.name, |
| description: tool.description, |
| parameters: tool.parameters ?? EMPTY_TOOL_PARAMETERS, |
| deferred: tool.deferred, |
| })); |
| } |
|
|
| private shouldRecoverFromContextOverflow( |
| error: unknown, |
| estimatedRequestTokens = this.currentRequestTokens(), |
| ): boolean { |
| if (isCodedError(error) && error.code === ErrorCodes.CONTEXT_OVERFLOW) return true; |
| const statusError = findAPIStatusError(error); |
| if (statusError instanceof APIContextOverflowError) return true; |
| if (statusError === undefined || statusError.statusCode !== 413) return false; |
| const effectiveMax = this.getEffectiveMaxContextTokens(); |
| return ( |
| effectiveMax > 0 && |
| estimatedRequestTokens >= effectiveMax * OVERFLOW_STATUS_RECOVERY_RATIO |
| ); |
| } |
|
|
| private observeContextOverflow(estimatedRequestTokens: number): void { |
| if (!Number.isFinite(estimatedRequestTokens) || estimatedRequestTokens <= 0) return; |
| const modelAlias = this.profile.data().modelAlias; |
| if (modelAlias === undefined) return; |
| const observed = Math.max( |
| 1, |
| Math.floor(estimatedRequestTokens * OVERFLOW_CONTEXT_SAFETY_RATIO), |
| ); |
| const current = this.getEffectiveMaxContextTokens(); |
| if (current > 0 && observed >= current) return; |
| this.observedMaxContextTokensByModel.set(modelAlias, observed); |
| } |
|
|
| begin(input: FullCompactionInput): boolean { |
| if (this._compacting) return false; |
| const data: CompactionBeginData = { source: input.source, instruction: input.instruction }; |
| if (!this.reserveCompactionSlot(data.source)) return false; |
|
|
| const tokenCount = this.validateCompactionStart(data.source); |
| const quiescence = data.source === 'manual' |
| ? this.loopService.tryAcquireQuiescence() |
| : undefined; |
| if (data.source === 'manual' && quiescence === undefined) { |
| throw new Error2( |
| ErrorCodes.COMPACTION_UNABLE, |
| 'Cannot compact while a turn is active or another context change is running. Wait for it to finish, then retry.', |
| ); |
| } |
| try { |
| void this.dispatcher.dispatch( |
| new FullCompactionBegin({ ...data, agentId: this.agent.agentId }), |
| ); |
|
|
| const active = this.createActiveCompaction( |
| data.source, |
| tokenCount, |
| data.source === 'auto' ? this.activeTurnId : undefined, |
| quiescence, |
| ); |
| this._compacting = active.task; |
| active.task.abortController.signal.addEventListener( |
| 'abort', |
| () => this.cancelActive(active.task), |
| { once: true }, |
| ); |
| void this.compactionWorker(active.task, data).then(active.resolve, active.reject); |
| void active.task.promise.catch(() => undefined); |
| return true; |
| } catch (error) { |
| quiescence?.dispose(); |
| throw error; |
| } |
| } |
|
|
| private reserveCompactionSlot(source: CompactionBeginData['source']): boolean { |
| if (source === 'manual') { |
| this.compactionCountInTurn = 0; |
| } else { |
| this.compactionCountInTurn += 1; |
| } |
| return this.compactionCountInTurn <= this.strategy.maxCompactionPerTurn; |
| } |
|
|
| private validateCompactionStart(source: CompactionBeginData['source']): number { |
| const history = this.context.get(); |
| if (history.length === 0) { |
| throw new Error2(ErrorCodes.COMPACTION_UNABLE, 'No messages to compact in current history.'); |
| } |
| if (source === 'manual' && this.loopService.snapshot().state !== 'idle') { |
| throw new Error2( |
| ErrorCodes.COMPACTION_UNABLE, |
| 'Cannot compact while a turn is active. Wait for it to finish, then retry.', |
| ); |
| } |
| return this.requestTokens(history); |
| } |
|
|
| private createActiveCompaction( |
| trigger: CompactionBeginData['source'], |
| tokenCount: number, |
| originTurnId: number | undefined, |
| quiescence: IDisposable | undefined, |
| ): { |
| readonly task: ActiveCompaction; |
| readonly resolve: (result: CompactionResult) => void; |
| readonly reject: (reason: unknown) => void; |
| } { |
| const abortController = new AbortController(); |
| let resolve!: (result: CompactionResult) => void; |
| let reject!: (reason: unknown) => void; |
| const promise = new Promise<CompactionResult>((onResolve, onReject) => { |
| resolve = onResolve; |
| reject = onReject; |
| }); |
| return { |
| task: { |
| abortController, |
| promise, |
| trigger, |
| tokenCount, |
| originTurnId, |
| quiescence, |
| get traceId() { |
| return this.trace?.traceId; |
| }, |
| blockedByTurn: false, |
| }, |
| resolve, |
| reject, |
| }; |
| } |
|
|
| override dispose(): void { |
| if (this._compacting !== null && !this._compacting.abortController.signal.aborted) { |
| this._compacting.abortController.abort(); |
| } |
| super.dispose(); |
| } |
|
|
| private cancelActive(active: ActiveCompaction): boolean { |
| if (this._compacting !== active) return false; |
| void this.dispatcher.dispatch(new FullCompactionCancel({ agentId: this.agent.agentId })); |
| this._compacting = null; |
| if (!active.abortController.signal.aborted) { |
| active.abortController.abort(); |
| } |
| void this.dispatcher.dispatch(new CompactionCancelled({ agentId: this.agent.agentId })); |
| return true; |
| } |
|
|
| private markCompleted(active: ActiveCompaction): boolean { |
| if (this._compacting !== active) return false; |
| void this.dispatcher.dispatch(new FullCompactionComplete({ agentId: this.agent.agentId })); |
| this._compacting = null; |
| return true; |
| } |
|
|
| private normalizeAfterReplay(): void { |
| if (this.states.get(fullCompactionKey).phase !== 'running') return; |
| void this.dispatcher.dispatch(new FullCompactionCancel({ agentId: this.agent.agentId })); |
| } |
|
|
| private resetForTurn(): void { |
| this.compactionCountInTurn = 0; |
| this.lastCompactedTokenCount = null; |
| this.consecutiveOverflowCompactions = 0; |
| } |
|
|
| private async recoverFromContextOverflow( |
| context: LoopErrorContext, |
| ): Promise<boolean> { |
| this.recordOverflowRecovery(context.error); |
| const didStartCompaction = this.beginAutoCompaction(); |
| if (!didStartCompaction && !this._compacting) return false; |
|
|
| await this.block(context.signal, context.turnId); |
| return this.retryFailedDriver(context); |
| } |
|
|
| private recordOverflowRecovery(error: unknown): void { |
| this.observeContextOverflow(this.currentRequestTokens()); |
| this.consecutiveOverflowCompactions += 1; |
| const maxAttempts = this.strategy.maxOverflowCompactionAttempts; |
| if (this.consecutiveOverflowCompactions <= maxAttempts) return; |
| throw new Error2( |
| ErrorCodes.CONTEXT_OVERFLOW, |
| `Compaction failed to bring the context under the model window after ${String(maxAttempts)} attempts.`, |
| { cause: error instanceof Error ? error : undefined }, |
| ); |
| } |
|
|
| private retryFailedDriver(context: LoopErrorContext): boolean { |
| if (context.signal.aborted) return false; |
| context.retry(); |
| return true; |
| } |
|
|
| private async beforeStep(signal: AbortSignal, turnId?: number): Promise<void> { |
| this.activeTurnId = turnId; |
| this.checkAutoCompaction(); |
| if (this.strategy.shouldBlock(this.tokenCountWithPending())) { |
| await this.block(signal, turnId); |
| } |
| } |
|
|
| private async afterStep(): Promise<void> { |
| this.consecutiveOverflowCompactions = 0; |
| if (this.strategy.checkAfterStep) { |
| this.checkAutoCompaction(false); |
| } |
| } |
|
|
| private checkAutoCompaction(throwOnLimit = true): boolean { |
| if (this._compacting) return true; |
| if ( |
| this.lastCompactedTokenCount !== null && |
| this.tokenCountWithPending() <= this.lastCompactedTokenCount |
| ) { |
| return false; |
| } |
| if (!this.strategy.shouldCompact(this.tokenCountWithPending())) return false; |
| return this.beginAutoCompaction(throwOnLimit); |
| } |
|
|
| private beginAutoCompaction(throwOnLimit = true): boolean { |
| if (this._compacting) return true; |
| const maxCompactions = this.strategy.maxCompactionPerTurn; |
| if (this.compactionCountInTurn >= maxCompactions) { |
| if (throwOnLimit) { |
| throw new Error2(ErrorCodes.CONTEXT_OVERFLOW, `Compaction limit exceeded (${String(maxCompactions)})`, { |
| details: { maxCompactions }, |
| }); |
| } |
| return false; |
| } |
| return this.begin({ source: 'auto' }); |
| } |
|
|
| private async block(signal?: AbortSignal, turnId?: number): Promise<void> { |
| const active = this._compacting; |
| if (active === null) return; |
| active.blockedByTurn = true; |
| this.propagateBlockingAbort(active, signal); |
| void this.dispatcher.dispatch( |
| new CompactionBlocked({ agentId: this.agent.agentId, turnId }), |
| ); |
| try { |
| await active.promise; |
| } catch (error) { |
| if (this.wasBlockingWaitAborted(active, signal, error)) return; |
| throw error; |
| } |
| } |
|
|
| private propagateBlockingAbort(active: ActiveCompaction, signal: AbortSignal | undefined): void { |
| signal?.addEventListener( |
| 'abort', |
| () => { |
| if (this._compacting === active) active.abortController.abort(); |
| }, |
| { once: true }, |
| ); |
| } |
|
|
| private wasBlockingWaitAborted( |
| active: ActiveCompaction, |
| signal: AbortSignal | undefined, |
| error: unknown, |
| ): boolean { |
| return ( |
| signal?.aborted === true && |
| (active.abortController.signal.aborted || isAbortError(error)) |
| ); |
| } |
|
|
| private async compactionWorker( |
| active: ActiveCompaction, |
| data: Readonly<CompactionBeginData>, |
| ): Promise<CompactionResult> { |
| try { |
| const result = await this.compactionRound(active, data); |
| if (this._compacting !== active) throw compactionCancelledReason(active); |
| this.lastCompactedTokenCount = result.tokensAfter; |
| if (!this.markCompleted(active)) { |
| throw compactionCancelledReason(active); |
| } |
| const { contextSummary: _contextSummary, ...eventResult } = result; |
| void _contextSummary; |
| void this.dispatcher.dispatch( |
| new CompactionCompleted({ agentId: this.agent.agentId, result: eventResult }), |
| ); |
| return result; |
| } catch (error) { |
| if (active.abortController.signal.aborted || isAbortError(error)) { |
| this.cancelActive(active); |
| throw error; |
| } |
| const blockedByTurn = this._compacting === active && active.blockedByTurn; |
| if (this._compacting === active) { |
| this.cancelActive(active); |
| } |
| if (blockedByTurn) { |
| throw error; |
| } |
| void this.dispatcher.dispatch( |
| new AgentErrorEvent({ ...toKimiErrorPayload(error), agentId: this.agent.agentId }), |
| ); |
| throw error; |
| } finally { |
| try { |
| this._onDidFinishCompaction.fire(active); |
| } finally { |
| active.quiescence?.dispose(); |
| } |
| } |
| } |
|
|
| private async compactionRound( |
| active: ActiveCompaction, |
| data: Readonly<CompactionBeginData>, |
| ): Promise<CompactionResult> { |
| const startedAt = Date.now(); |
| const originalHistory = [...this.context.get()]; |
| const tokensBefore = this.requestTokens(originalHistory); |
| let retryCount = 0; |
| let thinkingEffort = this.profile.data().thinkingLevel; |
|
|
| try { |
| const signal = active.abortController.signal; |
| signal.throwIfAborted(); |
|
|
| await this.hooks.onWillCompact.run(active); |
|
|
| const resolvedModel = this.profile.resolveModelContext(); |
| thinkingEffort = resolvedModel.thinkingLevel; |
| const maxContextTokens = resolvedModel.modelCapabilities.max_context_tokens; |
| const defaultCompactionCap = |
| maxContextTokens > 0 |
| ? Math.min(maxContextTokens, DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS) |
| : undefined; |
| const compactionMaxOutputSize = resolvedModel.maxOutputSize ?? defaultCompactionCap; |
|
|
| const instruction = renderCompactionInstruction({ customInstruction: data.instruction }); |
|
|
| const maxAttempts = resolvedModel.compactionMaxAttempts ?? MAX_COMPACTION_RETRY_ATTEMPTS; |
| let attempt: CompactionAttemptResult | undefined; |
| let historyForModel: readonly ContextMessage[] = stripDynamicToolContext(originalHistory); |
| let droppedCount = 0; |
| let overflowShrinkCount = 0; |
| let requestAttempts = 0; |
| while (true) { |
| const messagesToCompact = historyForModel; |
| const messages: Message[] = [...messagesToCompact, createUserMessage(instruction)]; |
| const estimatedCompactionRequestTokens = this.requestTokens(messages); |
| requestAttempts += 1; |
|
|
| try { |
| const runRequest = async () => { |
| const request = this.llmRequester.start( |
| { |
| messages, |
| maxOutputSize: compactionMaxOutputSize, |
| source: { |
| type: 'operation', |
| turnId: active.originTurnId, |
| requestKind: 'full_compaction', |
| logFields: { droppedCount }, |
| }, |
| }, |
| undefined, |
| signal, |
| ); |
| active.trace = request.trace; |
| return request.result; |
| }; |
| const result = await runWithCredentialRecovery( |
| this.llmRequester.currentCredentialProvider(), |
| runRequest, |
| signal, |
| ); |
| attempt = collectSummary(result); |
| break; |
| } catch (error) { |
| const isContextOverflow = this.shouldRecoverFromContextOverflow( |
| error, |
| estimatedCompactionRequestTokens, |
| ); |
| if (isContextOverflow) { |
| this.observeContextOverflow(estimatedCompactionRequestTokens); |
| overflowShrinkCount += 1; |
| if ( |
| overflowShrinkCount > MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS || |
| requestAttempts >= maxAttempts || |
| messagesToCompact.length <= 1 |
| ) { |
| throw error; |
| } |
| const before = messagesToCompact.length; |
| historyForModel = shrinkCompactionHistoryAfterOverflow( |
| messagesToCompact, |
| overflowShrinkCount, |
| (message) => this.tokenCounting.estimateMessage(message), |
| ); |
| if (historyForModel.length === 0) throw error; |
| droppedCount += before - historyForModel.length; |
| retryCount = 0; |
| continue; |
| } |
| const unwrappedError = unwrapErrorCause(error); |
| if ( |
| (error instanceof CompactionTruncatedError || |
| (unwrappedError instanceof APIEmptyResponseError && |
| unwrappedError.finishReason !== 'filtered')) && |
| messagesToCompact.length > 1 |
| ) { |
| if (requestAttempts >= maxAttempts) { |
| throw error; |
| } |
| const reduced = dropOldestMessageAndLeadingToolResults(messagesToCompact); |
| droppedCount += messagesToCompact.length - reduced.length; |
| historyForModel = reduced; |
| retryCount = 0; |
| continue; |
| } |
| if (!isRetryableGenerateError(unwrappedError)) { |
| throw error; |
| } |
| if (requestAttempts >= maxAttempts) { |
| throw error; |
| } |
| await sleepForRetry(retryBackoffDelay(retryCount), signal); |
| retryCount += 1; |
| } |
| } |
|
|
| if (attempt === undefined) { |
| throw new APIEmptyResponseError( |
| 'The compaction response did not contain a usable summary.', |
| ); |
| } |
|
|
| if (!historySafeToCompact(this.context.get(), originalHistory)) { |
| const active = this._compacting; |
| if (active !== null) { |
| this.cancelActive(active); |
| } |
| throw compactionCancelledReason(active); |
| } |
|
|
| const summary = await this.postProcessSummary(attempt.summary); |
| const wireLines = await this.captureWireLines(); |
| const recoveryFooter = this.renderRecoveryFooter(wireLines); |
| const summaryText = buildCompactionSummaryText(summary); |
| const result = this.context.applyCompaction({ |
| summary, |
| contextSummary: |
| recoveryFooter === undefined ? summaryText : `${summaryText}\n\n${recoveryFooter}`, |
| compactedCount: originalHistory.length, |
| tokensBefore, |
| summaryOutputTokens: |
| attempt.usage === null |
| ? undefined |
| : attempt.usage.output + |
| (recoveryFooter === undefined ? 0 : this.tokenCounting.estimateText(recoveryFooter)), |
| requestOverheadTokens: this.requestTokens([]), |
| droppedCount: droppedCount === 0 ? undefined : droppedCount, |
| wireLines, |
| }); |
|
|
| const properties: CompactionFinishedEvent = { |
| turn_id: active.originTurnId, |
| source: data.source, |
| tokens_before: result.tokensBefore, |
| tokens_after: result.tokensAfter, |
| duration_ms: Date.now() - startedAt, |
| compacted_count: result.compactedCount, |
| dropped_count: result.droppedCount, |
| retry_count: retryCount, |
| round: 1, |
| thinking_effort: thinkingEffort, |
| trace_id: attempt.traceId, |
| ...usageTelemetry(attempt.usage), |
| }; |
| this.telemetry.track2('compaction_finished', properties); |
| return result; |
| } catch (error) { |
| if (isAbortError(error)) throw error; |
| const properties: CompactionFailedEvent = { |
| turn_id: active.originTurnId, |
| source: data.source, |
| tokens_before: tokensBefore, |
| duration_ms: Date.now() - startedAt, |
| round: 1, |
| retry_count: retryCount, |
| thinking_effort: thinkingEffort, |
| error_type: error instanceof Error ? error.name : 'Unknown', |
| trace_id: findAPIStatusError(error)?.traceId ?? active.traceId, |
| }; |
| this.telemetry.track2('compaction_failed', properties); |
| if ( |
| isError2(error) && |
| (error.code === ErrorCodes.AUTH_LOGIN_REQUIRED || |
| error.code === ErrorCodes.PROVIDER_AUTH_ERROR) |
| ) { |
| throw error; |
| } |
| throw new Error2(ErrorCodes.COMPACTION_FAILED, String(error), { cause: error }); |
| } |
| } |
|
|
| private async postProcessSummary(summary: string): Promise<string> { |
| const todos = this.todo.get(); |
| if (todos.length === 0) { |
| return summary; |
| } |
| return `${summary.trim()}\n\n${renderTodoList(todos, '## TODO List')}`; |
| } |
|
|
| private async captureWireLines(): Promise<WireLineRange | undefined> { |
| try { |
| await this.wire.flush(); |
| } catch (error) { |
| onUnexpectedError(error); |
| return undefined; |
| } |
| const end = this.wire.lineCount(); |
| const previous = this.states.get(fullCompactionWireRangesKey).at(-1); |
| const start = Math.max(previous?.end ?? 0, this.wire.lastContextClearLine() ?? 0) + 1; |
| if (end < start) return undefined; |
| return { start, end }; |
| } |
|
|
| private renderRecoveryFooter(wireLines: WireLineRange | undefined): string | undefined { |
| if (wireLines === undefined) return undefined; |
| const journalPath = this.wire.journalPath(); |
| if (journalPath === undefined) return undefined; |
| const windows = [...this.states.get(fullCompactionWireRangesKey), wireLines]; |
| return renderContextRecoveryPointer({ journalPath, windows }); |
| } |
|
|
| private tokenCountWithPending(): number { |
| return this.tokenCounting.get(agentContextOfScope(this.agent)).size; |
| } |
| } |
|
|
| function findAPIStatusError(error: unknown): APIStatusError | undefined { |
| let current: unknown = error; |
| const seen = new Set<unknown>(); |
| while (current !== undefined && current !== null && !seen.has(current)) { |
| if (current instanceof APIStatusError) return current; |
| seen.add(current); |
| current = current instanceof Error ? current.cause : undefined; |
| } |
| return undefined; |
| } |
|
|
| function collectSummary(finish: AgentLLMRequestFinish): CompactionAttemptResult { |
| if (finish.providerFinishReason === 'truncated') { |
| throw new CompactionTruncatedError(); |
| } |
|
|
| const summary = finish.message.content |
| .filter((part) => part.type === 'text') |
| .map((part) => part.text) |
| .join('') |
| .trim(); |
| if (summary.length === 0) { |
| throw new APIEmptyResponseError( |
| 'The compaction response did not contain a non-empty summary.', |
| ); |
| } |
|
|
| return { summary, usage: finish.usage, traceId: finish.traceId }; |
| } |
|
|
| function historySafeToCompact( |
| current: readonly ContextMessage[], |
| original: readonly ContextMessage[], |
| ): boolean { |
| if (current.length < original.length) return false; |
| if (!original.every((message, index) => message === current[index])) return false; |
| return current.slice(original.length).every(isRealUserInput); |
| } |
|
|
| function shrinkCompactionHistoryAfterOverflow<T extends Message>( |
| messages: readonly T[], |
| attempt: number, |
| estimateMessage: (message: T) => number = estimateTokensForMessage, |
| ): T[] { |
| if (messages.length <= 1) return messages.slice(); |
| const ratio = COMPACTION_OVERFLOW_SHRINK_RATIOS[ |
| Math.min(attempt - 1, COMPACTION_OVERFLOW_SHRINK_RATIOS.length - 1) |
| ]!; |
| let totalTokens = 0; |
| for (const message of messages) totalTokens += estimateMessage(message); |
| const tokenBudget = Math.floor(totalTokens * ratio); |
| return takeRecentMessagesWithinTokenBudget(messages, tokenBudget, estimateMessage); |
| } |
|
|
| function takeRecentMessagesWithinTokenBudget<T extends Message>( |
| messages: readonly T[], |
| tokenBudget: number, |
| estimateMessage: (message: T) => number = estimateTokensForMessage, |
| ): T[] { |
| let start = messages.length; |
| let tokens = 0; |
| for (let i = messages.length - 1; i >= 0; i--) { |
| const messageTokens = estimateMessage(messages[i]!); |
| if (tokens + messageTokens > tokenBudget) break; |
| tokens += messageTokens; |
| start = i; |
| } |
| if (start === 0) start = 1; |
| return dropLeadingToolResults(messages.slice(start)); |
| } |
|
|
| function dropOldestMessageAndLeadingToolResults<T extends { readonly role: string }>( |
| messages: readonly T[], |
| ): T[] { |
| if (messages.length <= 1) return messages.slice(); |
| return dropLeadingToolResults(messages.slice(1)); |
| } |
|
|
| function dropLeadingToolResults<T extends { readonly role: string }>(messages: readonly T[]): T[] { |
| let start = 0; |
| while (start < messages.length && messages[start]!.role === 'tool') { |
| start += 1; |
| } |
| return messages.slice(start); |
| } |
|
|
| function usageTelemetry(usage: TokenUsage | null): CompactionTelemetryProperties { |
| if (usage === null) return {}; |
| return { |
| input_tokens: inputTotal(usage), |
| output_tokens: usage.output, |
| input_cache_read: usage.inputCacheRead, |
| input_cache_creation: usage.inputCacheCreation, |
| }; |
| } |
|
|
| function compactionCancelledReason(active: ActiveCompaction | null): Error { |
| const reason = active?.abortController.signal.reason; |
| if (reason instanceof Error) return reason; |
| const error = new Error('Compaction cancelled.'); |
| error.name = 'AbortError'; |
| return error; |
| } |
|
|
| registerScopedService( |
| LifecycleScope.Agent, |
| IAgentFullCompactionService, |
| AgentFullCompactionService, |
| ScopeActivation.OnScopeCreated, |
| 'fullCompaction', |
| ); |
|
|