From 0f77fa8a2eb9ccd0c23a4e25d97a74300ba8eda8 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 3 Jul 2026 22:59:42 +0800 Subject: [PATCH] fix: full compaction --- .../agent-core-v2/docs/di-scope-domains.puml | 1 - .../agent-core-v2/docs/di-scope-domains.svg | 2 +- .../contextMemory/contextMemoryService.ts | 21 +- .../agent/fullCompaction/fullCompaction.ts | 2 - .../fullCompaction/fullCompactionService.ts | 97 ++++--- .../src/agent/fullCompaction/strategy.ts | 11 + .../agent/llmRequester/llmRequesterService.ts | 8 +- .../test/contextMemory/splice-replay.test.ts | 164 ++++++++++++ .../test/contextProjector/projector.bench.ts | 236 ++++++++++++++++++ .../test/fullCompaction/full.test.ts | 4 + .../test/fullCompaction/strategy.test.ts | 3 + 11 files changed, 507 insertions(+), 42 deletions(-) create mode 100644 packages/agent-core-v2/test/contextMemory/splice-replay.test.ts create mode 100644 packages/agent-core-v2/test/contextProjector/projector.bench.ts diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml index 54dcede08..ce3b3abed 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.puml +++ b/packages/agent-core-v2/docs/di-scope-domains.puml @@ -279,7 +279,6 @@ mcp --> toolRegistry #34495E mcp --> record #34495E mcp --> toolExecutor #34495E fullCompaction --> contextMemory #34495E -fullCompaction --> contextProjector #34495E fullCompaction --> contextSize #34495E fullCompaction --> llmRequester #34495E fullCompaction --> profile #34495E diff --git a/packages/agent-core-v2/docs/di-scope-domains.svg b/packages/agent-core-v2/docs/di-scope-domains.svg index 39ab3b3d0..6e9632437 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.svg +++ b/packages/agent-core-v2/docs/di-scope-domains.svg @@ -1 +1 @@ -App scope (process-wide)Session scope (per session)Agent scope (per agent)environmenteventSinkbootstrapAppIBootstrapServicelogAppILogServiceILogWriterServicetelemetryAppITelemetryServiceeventAppIEventServicestorageAppIStorageServiceIAppendLogStoreIAtomicDocumentStoreIAtomicTomlDocumentStorefilestoreAppIFileStoregatewayAppIRestGatewayIWSGatewaysessionLifecycleAppISessionLifecycleServicesessionIndexAppISessionIndexhostFsAppIHostFileSystemworkspaceRegistryAppIWorkspaceRegistryhostFolderBrowserAppIHostFolderBrowserhostEnvironmentAppIHostEnvironmentauthAppIOAuthServiceIAuthSummaryServiceproviderAppIProviderServiceflagAppIFlagServiceIFlagRegistryconfigAppIConfigRegistryIConfigServicepluginAppIPluginServicechatProviderAppIChatProviderFactorymodelAppIModelServicemodelCatalogAppIModelCatalogServiceglobalSkillCatalogAppIGlobalSkillCatalogISkillCatalogStoresessionContextSessionISessionContext (seed)sessionMetadataSessionISessionMetadatasessionActivitySessionISessionActivityagentLifecycleSessionIAgentLifecycleServiceinteractionSessionIInteractionServiceworkspaceContextSessionIWorkspaceContextsessionLogSessionISessionLogServiceILogWriterServicesessionSkillCatalogSessionISessionSkillCatalogexecContextSessionIExecContext (seed)agentFsSessionIAgentFileSystemIFsServiceapprovalSessionIApprovalServicequestionSessionIQuestionServiceprocessSessionIProcessRunnerIProcessterminalSessionITerminalServiceITerminalBackendmodelProviderSessionIModelProvider (seed)wireRecordAgentIAgentWireRecordService (event hub)recordAgentIAgentRecordServiceblobStoreAgentIAgentBlobStoreServicecontextMemoryAgentIAgentContextMemoryServicecontextProjectorAgentIAgentContextProjectorServicecontextInjectorAgentIAgentContextInjectorServicecontextSizeAgentIAgentContextSizeServicesystemReminderAgentIAgentSystemReminderServiceprofileAgentIAgentProfileServicepromptAgentIAgentPromptServiceturnAgentIAgentTurnServiceloopAgentIAgentLoopServicellmRequesterAgentIAgentLLMRequesterServicetoolRegistryAgentIAgentToolRegistryServicetoolExecutorAgentIAgentToolExecutorServicetoolStoreAgentIAgentToolStoreServicetoolDedupAgentIAgentToolDedupeServicepermissionGateAgentIAgentPermissionGatepermissionModeAgentIAgentPermissionModeServicepermissionPolicyAgentIAgentPermissionPolicyServicepermissionRulesAgentIAgentPermissionRulesServiceplanAgentIAgentPlanServicegoalAgentIAgentGoalServiceskillAgentIAgentSkillServicequestionToolsAgentIAgentQuestionToolsServiceuserToolAgentIAgentUserToolServicetaskAgentIAgentTaskServicecronAgentIAgentCronServiceswarmAgentIAgentSwarmServicemcpAgentIAgentMcpServicefullCompactionAgentIAgentFullCompactionServicemicroCompactionAgentIAgentMicroCompactionServiceexternalHooksAgentIAgentExternalHooksServicetodoListAgentIAgentTodoListServiceusageAgentIAgentUsageServicerpcAgentIAgentRPCServicefileToolsAgentIAgentFileToolsServiceshellToolsAgentIAgentShellToolsServiceagentToolAgentIAgentToolServicescopeContextAgentIAgentScopeContext (seed)onDidChangeConfigurationpermission.set_modetools.register_/unregister_user_toolconfig.update / tools.set_active_toolscontext.splicecron.add / delete / cursorfull_compaction.begin/cancel/completehooks.onContextOverflowpermission.rules.add / record_approval_resultskill.activategoal.create/update/clearturn lifecycle hooksstep/usage hookshook.result / goal.updatedmicro_compaction.apply / full_compaction.completeswarm_mode.enter/exithooks.onEndedtask.started/terminatedhooks.onSplicedonWillExecuteTool / onDidExecuteToolapproval hooksprompt/end hooksstop hookcompaction hooksnotification hooksubagent hookstools.update_storeusage.recordhooks.onSplicedcontext_size.measuredhooks.onSplicedhooks.onResumeEndedplan_mode.enter/cancel/exitScope = node color      App (process-wide)      Session (per session)      Agent (per agent)Edgessolid: DI injection (ctor @IX)dashed: event-driven (subscribe/emit)direction: consumer ---> providerNotesGenerated from `node scripts/dep-graph.mjs` output;`_base` / seed / options deps are omitted. \ No newline at end of file +App scope (process-wide)Session scope (per session)Agent scope (per agent)bootstrapAppIBootstrapServicelogAppILogServiceILogWriterServicetelemetryAppITelemetryServiceeventAppIEventServicestorageAppIStorageServiceIAppendLogStoreIAtomicDocumentStoreIAtomicTomlDocumentStorefilestoreAppIFileStoregatewayAppIRestGatewayIWSGatewaysessionLifecycleAppISessionLifecycleServicesessionIndexAppISessionIndexhostFsAppIHostFileSystemworkspaceRegistryAppIWorkspaceRegistryhostFolderBrowserAppIHostFolderBrowserhostEnvironmentAppIHostEnvironmentauthAppIOAuthServiceIAuthSummaryServiceIWebSearchProviderServicewebAppIWebFetchServiceproviderAppIProviderServiceflagAppIFlagServiceIFlagRegistryconfigAppIConfigRegistryIConfigServicepluginAppIPluginServicechatProviderAppIChatProviderFactorymodelAppIModelServicemodelCatalogAppIModelCatalogServiceglobalSkillCatalogAppIGlobalSkillCatalogISkillCatalogStoresessionContextSessionISessionContext (seed)sessionMetadataSessionISessionMetadatasessionActivitySessionISessionActivityagentLifecycleSessionIAgentLifecycleServiceinteractionSessionIInteractionServiceworkspaceContextSessionIWorkspaceContextsessionLogSessionISessionLogServiceILogWriterServicesessionSkillCatalogSessionISessionSkillCatalogexecContextSessionIExecContext (seed)agentFsSessionIAgentFileSystemIFsServiceapprovalSessionIApprovalServicequestionSessionIQuestionServiceprocessSessionIProcessRunnerIProcessterminalAppIHostTerminalServicesessionTerminalSessionISessionTerminalServicemodelProviderSessionIModelProvider (seed)wireRecordAgentIAgentWireRecordService (event hub)recordAgentIAgentRecordServiceblobStoreAgentIAgentBlobStoreServicecontextMemoryAgentIAgentContextMemoryServicecontextProjectorAgentIAgentContextProjectorServicecontextInjectorAgentIAgentContextInjectorServicecontextSizeAgentIAgentContextSizeServicesystemReminderAgentIAgentSystemReminderServiceprofileAgentIAgentProfileServicepromptAgentIAgentPromptServiceturnAgentIAgentTurnServiceloopAgentIAgentLoopServicellmRequesterAgentIAgentLLMRequesterServicetoolRegistryAgentIAgentToolRegistryServicetoolExecutorAgentIAgentToolExecutorServicetoolStoreAgentIAgentToolStoreServicetoolDedupAgentIAgentToolDedupeServicepermissionGateAgentIAgentPermissionGatepermissionModeAgentIAgentPermissionModeServicepermissionPolicyAgentIAgentPermissionPolicyServicepermissionRulesAgentIAgentPermissionRulesServiceplanAgentIAgentPlanServicegoalAgentIAgentGoalServiceskillAgentIAgentSkillServicequestionToolsAgentIAgentQuestionToolsServiceuserToolAgentIAgentUserToolServicetaskAgentIAgentTaskServicecronAgentIAgentCronServiceswarmAgentIAgentSwarmServicemcpAgentIAgentMcpServicefullCompactionAgentIAgentFullCompactionServicemicroCompactionAgentIAgentMicroCompactionServiceexternalHooksAgentIAgentExternalHooksServicetodoListAgentIAgentTodoListServiceusageAgentIAgentUsageServicerpcAgentIAgentRPCServicefileToolsAgentIAgentFileToolsServiceshellToolsAgentIAgentShellToolsServicescopeContextAgentIAgentScopeContext (seed)environmenteventSinkonDidChangeConfigurationcontext.splicehooks.onSplicedcontext_size.measuredconfig.update / tools.set_active_toolshooks.onSplicedtools.update_storepermission.set_modepermission.rules.add / record_approval_resultplan_mode.enter/cancel/exitgoal.create/update/clearturn lifecycle hooksstep/usage hooksskill.activatetools.register_/unregister_user_tooltask.started/terminatedhooks.onSplicedcron.add / delete / cursorswarm_mode.enter/exitfull_compaction.begin/cancel/completehooks.onContextOverflowmicro_compaction.apply / full_compaction.completeusage.recordhook.result / goal.updatedhooks.onEndedonWillExecuteTool / onDidExecuteToolapproval hooksprompt/end hooksstop hookcompaction hooksnotification hookSubagentStart/Stop (mirrorAgentRun)hooks.onResumeEndedScope = node color      App (process-wide)      Session (per session)      Agent (per agent)Edgessolid: DI injection (ctor @IX)dashed: event-driven (subscribe/emit)direction: consumer ---> providerNotesGenerated from `node scripts/dep-graph.mjs` output;`_base` / seed / options deps are omitted. \ No newline at end of file diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index 6e83881c7..c0f32e960 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -79,15 +79,22 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte } private applySplice(record: AgentRecord<'context.splice'>): void { - const removedMessages = - record.deleteCount > 0 && record.start > 0 - ? this.history.slice(record.start, record.start + record.deleteCount) - : []; + // A boundary splice (`start === 0 && deleteCount > 0`, i.e. compaction or + // clear — see `isUndoBoundaryRecord`) never touches the replay: the removed + // transcript stays visible, and what it inserts (a compaction summary) is + // context machinery represented by its owner's record, not a message. + // Every other splice mirrors itself into the replay. + const boundary = record.start === 0 && record.deleteCount > 0; + const removedMessages = boundary + ? [] + : this.history.slice(record.start, record.start + record.deleteCount); const messages = record.messages.map(ensureMessageId); this.history.splice(record.start, record.deleteCount, ...messages); - this.record.removeLastMessages(new Set(removedMessages)); - for (const message of messages) { - this.record.push({ type: 'message', message }); + if (!boundary) { + this.record.removeLastMessages(new Set(removedMessages)); + for (const message of messages) { + this.record.push({ type: 'message', message }); + } } void this.hooks.onSpliced.run({ start: record.start, diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts index 671627b5a..6942c986a 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts @@ -10,8 +10,6 @@ export type FullCompactionCompleteData = Omit; export interface CompactInput { readonly source: CompactionSource; readonly instruction?: string; - readonly customInstruction?: string; - readonly signal?: AbortSignal; } export interface FullCompactionWillCompactContext { diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index e2cc2d503..6811ac784 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -7,7 +7,6 @@ import { renderPrompt } from "#/_base/utils/render-prompt"; import { estimateTokens, estimateTokensForMessages } from "#/_base/utils/tokens"; import type { ContextMessage } from '#/agent/contextMemory'; import { IAgentContextMemoryService } from '#/agent/contextMemory'; -import { IAgentContextProjectorService } from '#/agent/contextProjector'; import { IAgentContextSizeService } from '#/agent/contextSize'; import { IAgentLLMRequesterService, @@ -30,6 +29,7 @@ import { APIContextOverflowError, APIEmptyResponseError, createUserMessage, + type Message, type TokenUsage } from '#/app/llmProtocol'; import { ITelemetryService } from '#/app/telemetry'; @@ -81,7 +81,6 @@ interface ActiveCompaction { interface CompactionAttemptResult { readonly summary: string; readonly usage: TokenUsage | null; - readonly model: string | undefined; } class CompactionTruncatedError extends Error { @@ -101,11 +100,20 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull private readonly strategy: CompactionStrategy; private compactionCountInTurn = 0; private compacting: ActiveCompaction | null = null; + // Token count right after the last successful compaction. While nothing new + // has been appended, the history is already in its minimal compacted form; + // re-compacting would only summarize the summary again, so + // checkAutoCompaction skips in that case. + private lastCompactedTokenCount: number | null = null; + // Counts provider-overflow recoveries in this turn that have not yet been + // followed by a successful step. Trips maxOverflowCompactionAttempts to + // stop an overflow -> compact -> overflow loop when compaction can no + // longer shrink the request below the model window. + private consecutiveOverflowCompactions = 0; constructor( private readonly options: FullCompactionServiceOptions = {}, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentContextProjectorService private readonly projector: IAgentContextProjectorService, @IAgentContextSizeService private readonly contextSize: IAgentContextSizeService, @IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService, @IAgentProfileService private readonly profile: IAgentProfileService, @@ -162,10 +170,11 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull this._register( record.define('full_compaction.complete', { resume: (r) => { + // The summary message never enters the replay (its splice is a + // boundary); the compaction record is its only replay presence. const message = compactionSummaryMessage(this.context.get()); if (message === undefined) return; const summary = contextMessageText(message); - this.record.removeLastMessages(new Set([message])); this.record.patchLast('compaction', { result: { summary, @@ -185,7 +194,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull begin(input: CompactInput): boolean { if (this.compacting) return false; - const data = this.beginData(input); + const data: CompactionBeginData = { source: input.source, instruction: input.instruction }; if (data.source === 'manual') { this.compactionCountInTurn = 0; } else { @@ -233,12 +242,23 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull private resetForTurn(): void { this.compactionCountInTurn = 0; + this.lastCompactedTokenCount = null; + this.consecutiveOverflowCompactions = 0; } private async onContextOverflow( context: TurnContextOverflowContext, next: () => Promise, ): Promise { + this.consecutiveOverflowCompactions += 1; + const maxAttempts = this.strategy.maxOverflowCompactionAttempts; + if (this.consecutiveOverflowCompactions > maxAttempts) { + throw new KimiError( + ErrorCodes.CONTEXT_OVERFLOW, + `Compaction failed to bring the context under the model window after ${String(maxAttempts)} attempts.`, + { cause: context.error instanceof Error ? context.error : undefined }, + ); + } const didStartCompaction = this.beginAutoCompaction(); if (!didStartCompaction && !this.compacting) { await next(); @@ -256,6 +276,10 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull } private async afterStep(): Promise { + // A completed step means a request succeeded, so any prior + // overflow -> compact cycle produced a request that now fits; clear the + // loop guard. + this.consecutiveOverflowCompactions = 0; if (this.strategy.checkAfterStep) { this.checkAutoCompaction(false); } @@ -263,6 +287,12 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull 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); } @@ -328,6 +358,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull } if (this.compacting !== active) return; + this.lastCompactedTokenCount = finalResult.tokensAfter; this.markCompleted(completeData(finalResult)); this.record.signal({ type: 'compaction.completed', result: finalResult }); void this.hooks.onDidCompact.run({ @@ -365,11 +396,15 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull let compactedCount = initialCompactedCount; signal.throwIfAborted(); - await this.hooks.onWillCompact.run({ - trigger: data.source, - tokenCount: tokensBefore, - signal, - }); + // One logical compaction fires the hook once, even when it takes + // multiple window-sized rounds to bring the context under the ratio. + if (round === 1) { + await this.hooks.onWillCompact.run({ + trigger: data.source, + tokenCount: tokensBefore, + signal, + }); + } const resolvedModel = this.profile.resolveModelContext(); const maxContextTokens = resolvedModel.modelCapabilities.max_context_tokens; @@ -379,16 +414,17 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull : undefined; const compactionMaxOutputSize = resolvedModel.maxOutputSize ?? defaultCompactionCap; + const instruction = renderPrompt(compactionInstructionTemplate, { + customInstruction: data.instruction?.trim() ?? '', + }).trimEnd(); + const delays = retryBackoffDelays(MAX_COMPACTION_RETRY_ATTEMPTS); let attempt: CompactionAttemptResult | undefined; while (true) { const messagesToCompact = originalHistory.slice(0, compactedCount); - const messages = [ - ...this.projector.project(messagesToCompact), - createUserMessage(renderPrompt(compactionInstructionTemplate, { - customInstruction: data.instruction ?? '', - })), - ]; + // Raw context slice — `llmRequester` projects every request once; + // projecting here too would run micro-compaction on shifted indices. + const messages: Message[] = [...messagesToCompact, createUserMessage(instruction)]; try { attempt = collectSummary( @@ -409,7 +445,13 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull error instanceof CompactionTruncatedError || error instanceof APIEmptyResponseError ) { - compactedCount = this.strategy.reduceCompactOnOverflow(messagesToCompact); + const reduced = this.strategy.reduceCompactOnOverflow(messagesToCompact); + // An overflow that cannot shrink further would replay the same + // request; give up (v1: throws when the history cannot shrink). + if (error instanceof APIContextOverflowError && reduced >= compactedCount) { + throw error; + } + compactedCount = reduced; } else { throw error; } @@ -443,6 +485,8 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull }; this.telemetry.track('compaction_finished', { + // Never send `data.instruction` (user-authored content) to telemetry. + source: data.source, tokens_before: result.tokensBefore, tokens_after: result.tokensAfter, duration_ms: Date.now() - startedAt, @@ -451,7 +495,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull round, thinking_level: this.profile.data().thinkingLevel, ...usageTelemetry(attempt.usage), - ...data, }); this.context.splice( @@ -464,7 +507,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull } catch (error) { if (isAbortError(error)) return undefined; this.telemetry.track('compaction_failed', { - ...data, + source: data.source, tokens_before: tokensBefore, duration_ms: Date.now() - startedAt, round, @@ -497,13 +540,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull private tokenCountWithPending(): number { return this.contextSize.getStatus().contextTokensWithPending; } - - private beginData(input: CompactInput): CompactionBeginData { - return { - source: input.source, - instruction: input.instruction ?? input.customInstruction, - }; - } } function collectSummary(finish: LLMRequestFinish): CompactionAttemptResult { @@ -522,7 +558,7 @@ function collectSummary(finish: LLMRequestFinish): CompactionAttemptResult { ); } - return { summary, usage: finish.usage, model: finish.model }; + return { summary, usage: finish.usage }; } function createCompactionSummaryMessage(summary: string): ContextMessage { @@ -559,9 +595,10 @@ function historyUnchanged( original: readonly ContextMessage[], ): boolean { // Only the compacted prefix must be intact. Messages appended to the tail - // while the summary request was in flight are fine — the splice replaces just - // the prefix and leaves the appended tail in place (matching legacy, which - // compared `newHistory[i] !== originalHistory[i]` over the original length). + // while the summary request was in flight are fine — unlike legacy's + // whole-history rebuild (which had to cancel when non-user messages grew the + // tail), the splice replaces just the prefix and leaves the appended tail in + // place, so nothing appended concurrently can be lost. if (current.length < original.length) return false; return original.every((message, index) => message === current[index]); } diff --git a/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts b/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts index bcf290e65..0659b8360 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts @@ -8,6 +8,7 @@ export interface CompactionConfig { blockRatio: number; reservedContextSize: number; maxCompactionPerTurn: number; + maxOverflowCompactionAttempts: number; maxRecentMessages: number; maxRecentUserMessages: number; maxRecentSizeRatio: number; @@ -19,6 +20,7 @@ export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = { blockRatio: 0.85, // Same as triggerRatio to disable async compaction reservedContextSize: 50_000, maxCompactionPerTurn: Infinity, + maxOverflowCompactionAttempts: 3, maxRecentMessages: 4, maxRecentUserMessages: Infinity, maxRecentSizeRatio: 0.2, @@ -32,6 +34,7 @@ export interface CompactionStrategy { reduceCompactOnOverflow(messages: readonly Message[]): number; readonly checkAfterStep: boolean; readonly maxCompactionPerTurn: number; + readonly maxOverflowCompactionAttempts: number; } export class RuntimeCompactionStrategy implements CompactionStrategy { @@ -61,6 +64,10 @@ export class RuntimeCompactionStrategy implements CompactionStrategy { return DEFAULT_COMPACTION_CONFIG.maxCompactionPerTurn; } + get maxOverflowCompactionAttempts(): number { + return DEFAULT_COMPACTION_CONFIG.maxOverflowCompactionAttempts; + } + private delegate(): DefaultCompactionStrategy { const model = this.context(); return new DefaultCompactionStrategy( @@ -233,6 +240,10 @@ export class DefaultCompactionStrategy implements CompactionStrategy { get maxCompactionPerTurn(): number { return this.config.maxCompactionPerTurn; } + + get maxOverflowCompactionAttempts(): number { + return this.config.maxOverflowCompactionAttempts; + } } /** diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 42e20d392..d1cd276eb 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -288,7 +288,13 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { this.config.get('modelOverrides')?.maxCompletionTokens, }), capability: resolved.modelCapabilities, - usedContextTokens: this.contextSize.getStatus().contextTokens, + // The remaining-window clamp only applies to requests built from the + // live context; overridden messages (e.g. compaction) are sized + // independently and would be squeezed to nothing at high water marks. + usedContextTokens: + overrides.messages === undefined + ? this.contextSize.getStatus().contextTokens + : undefined, }); const messages = overrides.messages ?? this.context.get(); diff --git a/packages/agent-core-v2/test/contextMemory/splice-replay.test.ts b/packages/agent-core-v2/test/contextMemory/splice-replay.test.ts new file mode 100644 index 000000000..65ef60d84 --- /dev/null +++ b/packages/agent-core-v2/test/contextMemory/splice-replay.test.ts @@ -0,0 +1,164 @@ +/** + * `AgentContextMemoryService.applySplice` replay contract, exercised without + * the full agent harness: a boundary splice (`start === 0 && deleteCount > 0`, + * i.e. compaction/clear) never touches the replay; every other splice mirrors + * itself (removes deleted messages, pushes inserted ones). + */ + +import { describe, expect, it } from 'vitest'; + +import { + AgentContextMemoryService, + type ContextMessage, +} from '#/agent/contextMemory'; +import type { AgentRecord, IAgentRecordService } from '#/agent/record'; +import type { AgentReplayRecordPayload } from '#/agent/replayBuilder/types'; +import { stubRecord } from './stubs'; + +interface RecordingRecordStub { + readonly record: IAgentRecordService; + readonly pushed: AgentReplayRecordPayload[]; + readonly removed: ContextMessage[][]; + readonly resume: (record: AgentRecord<'context.splice'>) => void; +} + +function recordingRecord(): RecordingRecordStub { + const pushed: AgentReplayRecordPayload[] = []; + const removed: ContextMessage[][] = []; + let resume: ((record: AgentRecord<'context.splice'>) => void) | undefined; + const base = stubRecord(); + const record: IAgentRecordService = { + ...base, + define: (type, facets) => { + if (type === 'context.splice' && facets.resume !== undefined) { + resume = facets.resume as unknown as (record: AgentRecord<'context.splice'>) => void; + } + return base.define(type, facets); + }, + push: (payload) => { + pushed.push(payload); + }, + removeLastMessages: (messages) => { + if (messages.size > 0) removed.push([...messages]); + }, + }; + return { + record, + pushed, + removed, + resume: (spliceRecord) => { + if (resume === undefined) throw new Error('context.splice resumer not registered'); + resume(spliceRecord); + }, + }; +} + +function userMessage(text: string): ContextMessage { + return { role: 'user', content: [{ type: 'text', text }], toolCalls: [] }; +} + +function summaryMessage(text: string): ContextMessage { + return { + role: 'assistant', + content: [{ type: 'text', text }], + toolCalls: [], + origin: { kind: 'compaction_summary' }, + }; +} + +describe('AgentContextMemoryService splice replay contract', () => { + it('pushes appended messages into the replay', () => { + const stub = recordingRecord(); + const context = new AgentContextMemoryService(stub.record); + + context.splice(0, 0, [userMessage('hello')]); + context.splice(context.get().length, 0, [userMessage('world')]); + + expect(stub.pushed).toHaveLength(2); + expect(stub.pushed[1]).toMatchObject({ + type: 'message', + message: expect.objectContaining({ content: [{ type: 'text', text: 'world' }] }), + }); + expect(stub.removed).toHaveLength(0); + }); + + it('mirrors mid-history removals (undo-shaped splices) into the replay', () => { + const stub = recordingRecord(); + const context = new AgentContextMemoryService(stub.record); + context.splice(0, 0, [userMessage('keep'), userMessage('drop')]); + + context.splice(1, 1, []); + + expect(context.get().map((m) => m.content)).toEqual([[{ type: 'text', text: 'keep' }]]); + expect(stub.removed).toHaveLength(1); + expect(stub.removed[0]![0]).toMatchObject({ content: [{ type: 'text', text: 'drop' }] }); + }); + + it('mirrors in-place replacements (migrated step updates) into the replay', () => { + const stub = recordingRecord(); + const context = new AgentContextMemoryService(stub.record); + context.splice(0, 0, [userMessage('prompt'), userMessage('partial')]); + stub.pushed.length = 0; + + context.splice(1, 1, [userMessage('final')]); + + expect(stub.removed).toHaveLength(1); + expect(stub.removed[0]![0]).toMatchObject({ content: [{ type: 'text', text: 'partial' }] }); + expect(stub.pushed).toHaveLength(1); + expect(stub.pushed[0]).toMatchObject({ + type: 'message', + message: expect.objectContaining({ content: [{ type: 'text', text: 'final' }] }), + }); + }); + + it('leaves the replay untouched for boundary splices (compaction)', () => { + const stub = recordingRecord(); + const context = new AgentContextMemoryService(stub.record); + context.splice(0, 0, [userMessage('old 1'), userMessage('old 2')]); + stub.pushed.length = 0; + + context.splice(0, 2, [summaryMessage('summary')]); + + expect(context.get()).toHaveLength(1); + expect(stub.removed).toHaveLength(0); + expect(stub.pushed).toHaveLength(0); + }); + + it('leaves the replay untouched for boundary splices (clear)', () => { + const stub = recordingRecord(); + const context = new AgentContextMemoryService(stub.record); + context.splice(0, 0, [userMessage('one'), userMessage('two')]); + stub.pushed.length = 0; + + context.splice(0, context.get().length, []); + + expect(context.get()).toHaveLength(0); + expect(stub.removed).toHaveLength(0); + expect(stub.pushed).toHaveLength(0); + }); + + it('applies the same contract on the resume path', () => { + const stub = recordingRecord(); + const context = new AgentContextMemoryService(stub.record); + + stub.resume({ + type: 'context.splice', + start: 0, + deleteCount: 0, + messages: [userMessage('restored 1'), userMessage('restored 2')], + }); + stub.resume({ + type: 'context.splice', + start: 0, + deleteCount: 2, + messages: [summaryMessage('restored summary')], + }); + + expect(context.get()).toHaveLength(1); + // Appends were pushed; the boundary splice neither removed nor pushed, so + // the restored transcript keeps the pre-compaction messages and the + // summary never appears as a plain message. + expect(stub.pushed).toHaveLength(2); + expect(stub.removed).toHaveLength(0); + }); +}); diff --git a/packages/agent-core-v2/test/contextProjector/projector.bench.ts b/packages/agent-core-v2/test/contextProjector/projector.bench.ts new file mode 100644 index 000000000..dc9fbb12e --- /dev/null +++ b/packages/agent-core-v2/test/contextProjector/projector.bench.ts @@ -0,0 +1,236 @@ +/** + * Benchmark for the context projection rewrite (two-pass -> single-pass with + * slot backfill, and O(k²) -> O(k) adjacent user-prompt merging). + * + * `projectLegacy` below is the previous implementation, copied verbatim so the + * comparison stays runnable after the old code is gone. The "new" side goes + * through the real `AgentContextProjectorService` with micro-compaction + * stubbed to a pass-through, so it measures exactly the projection path. + * + * Run: + * pnpm --filter @moonshot-ai/agent-core-v2 exec vitest bench test/contextProjector/projector.bench.ts + */ + +import { bench, describe } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import type { ContextMessage } from '#/agent/contextMemory'; +import { + AgentContextProjectorService, + IAgentContextProjectorService, +} from '#/agent/contextProjector'; +import { IAgentMicroCompactionService } from '#/agent/microCompaction'; +import { ErrorCodes, KimiError } from '#/errors'; +import type { ContentPart, Message, TextPart, ToolCall } from '#/app/llmProtocol'; + +// --------------------------------------------------------------------------- +// Legacy implementation (verbatim copy of the pre-rewrite `project`) +// --------------------------------------------------------------------------- + +function projectLegacy(history: readonly ContextMessage[]): Message[] { + const openCalls = new Map(); + const answers = new Map(); + let hasAssistant = false; + for (const message of history) { + if (message.partial === true) continue; + if (message.role === 'assistant') { + hasAssistant = true; + for (const call of message.toolCalls) openCalls.set(call.id, call); + } else if (message.role === 'tool' && message.toolCallId !== undefined) { + const call = openCalls.get(message.toolCallId); + if (call === undefined) continue; + answers.set(call, message); + openCalls.delete(message.toolCallId); + } + } + + const out: Message[] = []; + let mergeSource: ContextMessage | undefined; + + const emit = (source: ContextMessage): void => { + const content = source.content.some(isBlankText) + ? source.content.filter((part) => !isBlankText(part)) + : source.content; + if (source.role === 'tool' && content.length === 0) { + throw new KimiError( + ErrorCodes.REQUEST_INVALID, + 'Tool result message content cannot be empty after removing empty text blocks.', + { details: { toolCallId: source.toolCallId } }, + ); + } + if (content.length === 0 && source.toolCalls.length === 0) return; + + const message = content === source.content ? source : { ...source, content }; + if (mergeSource !== undefined && canMergeUserMessage(message)) { + mergeSource = mergeTwoUserMessages(mergeSource, message); + out[out.length - 1] = stripContextMetadata(mergeSource); + return; + } + mergeSource = canMergeUserMessage(message) ? message : undefined; + out.push(stripContextMetadata(message)); + }; + + for (const message of history) { + if (message.partial === true) continue; + if (message.role === 'tool') { + if (!hasAssistant) emit(message); + continue; + } + emit(message); + for (const call of message.toolCalls) { + emit(answers.get(call) ?? createInterruptedToolResult(call.id)); + } + } + return out; +} + +const TOOL_INTERRUPTED_TEXT = + 'ERROR: Tool execution failed.\n' + + 'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.'; + +function createInterruptedToolResult(toolCallId: string): ContextMessage { + return { + role: 'tool', + content: [{ type: 'text', text: TOOL_INTERRUPTED_TEXT }], + toolCalls: [], + toolCallId, + isError: true, + }; +} + +function isBlankText(part: ContentPart): boolean { + return part.type === 'text' && part.text.trim().length === 0; +} + +function canMergeUserMessage(message: ContextMessage): boolean { + return message.role === 'user' && message.origin?.kind === 'user'; +} + +function mergeTwoUserMessages(a: ContextMessage, b: ContextMessage): ContextMessage { + const text = [a, b].map(extractText).filter((t) => t.length > 0).join('\n\n'); + const content: ContentPart[] = text === '' ? [] : [{ type: 'text', text }]; + content.push( + ...a.content.filter((part) => part.type !== 'text'), + ...b.content.filter((part) => part.type !== 'text'), + ); + return { role: 'user', content, toolCalls: [], origin: a.origin }; +} + +function extractText(message: ContextMessage): string { + return message.content + .filter((part): part is TextPart => part.type === 'text') + .map((part) => part.text) + .join(''); +} + +function stripContextMetadata(message: ContextMessage): Message { + return { + role: message.role, + name: message.name, + content: message.content.map((part) => ({ ...part })) as ContentPart[], + toolCalls: message.toolCalls.map((toolCall) => ({ ...toolCall })), + toolCallId: message.toolCallId, + partial: message.partial, + }; +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function makeExchangeHistory(exchanges: number, callsPerStep: number): ContextMessage[] { + const history: ContextMessage[] = []; + for (let i = 0; i < exchanges; i++) { + history.push({ + role: 'user', + content: [{ type: 'text', text: `reminder ${i}` }], + toolCalls: [], + origin: { kind: 'injection', variant: 'host' }, + }); + const ids = Array.from({ length: callsPerStep }, (_, j) => `c${i}_${j}`); + history.push({ + role: 'assistant', + content: [{ type: 'text', text: `step ${i}` }], + toolCalls: ids.map((id) => ({ type: 'function', id, name: 'Lookup', arguments: '{}' })), + }); + for (const id of ids) { + history.push({ + role: 'tool', + content: [{ type: 'text', text: `result for ${id} `.repeat(20) }], + toolCalls: [], + toolCallId: id, + }); + } + } + return history; +} + +function makeMergeHistory(count: number, textSize: number): ContextMessage[] { + const text = 'x'.repeat(textSize); + return Array.from({ length: count }, (_, i) => ({ + role: 'user' as const, + content: [{ type: 'text' as const, text: `${i} ${text}` }], + toolCalls: [], + origin: { kind: 'user' as const }, + })); +} + +function makeMixedHistory(turns: number): ContextMessage[] { + const history: ContextMessage[] = []; + for (let i = 0; i < turns; i++) { + history.push(...makeMergeHistory(3, 200).map((m) => ({ ...m }))); + history.push(...makeExchangeHistory(4, 2)); + } + return history; +} + +function createProjector(disposables: DisposableStore): IAgentContextProjectorService { + const ix = disposables.add(new TestInstantiationService()); + ix.set(IAgentContextProjectorService, new SyncDescriptor(AgentContextProjectorService)); + ix.stub(IAgentMicroCompactionService, { compact: (messages) => messages }); + return ix.get(IAgentContextProjectorService); +} + +// --------------------------------------------------------------------------- +// Benchmarks +// --------------------------------------------------------------------------- + +const disposables = new DisposableStore(); +const projector = createProjector(disposables); + +const TYPICAL = makeMixedHistory(4); // ~76 messages, a normal mid-session turn +const EXCHANGE_HEAVY = makeExchangeHistory(1000, 4); // 6000 messages of tool exchanges +const MERGE_HEAVY = makeMergeHistory(2000, 500); // 2000 adjacent user prompts + +// Long warmup and sample windows: the large fixtures allocate multi-thousand +// element outputs per iteration, so short runs are dominated by GC noise. +const OPTIONS = { warmupTime: 500, time: 3000 }; + +describe(`typical mid-session history (${TYPICAL.length} messages)`, () => { + bench('legacy (two-pass)', () => { + projectLegacy(TYPICAL); + }, OPTIONS); + bench('current (single-pass)', () => { + projector.project(TYPICAL); + }, OPTIONS); +}); + +describe(`tool-exchange heavy history (${EXCHANGE_HEAVY.length} messages)`, () => { + bench('legacy (two-pass)', () => { + projectLegacy(EXCHANGE_HEAVY); + }, OPTIONS); + bench('current (single-pass)', () => { + projector.project(EXCHANGE_HEAVY); + }, OPTIONS); +}); + +describe(`adjacent user-prompt merging (${MERGE_HEAVY.length} messages x 500 chars)`, () => { + bench('legacy (O(k²) re-merge)', () => { + projectLegacy(MERGE_HEAVY); + }, OPTIONS); + bench('current (O(k) accumulation)', () => { + projector.project(MERGE_HEAVY); + }, OPTIONS); +}); diff --git a/packages/agent-core-v2/test/fullCompaction/full.test.ts b/packages/agent-core-v2/test/fullCompaction/full.test.ts index 1df90eec2..076df5b6f 100644 --- a/packages/agent-core-v2/test/fullCompaction/full.test.ts +++ b/packages/agent-core-v2/test/fullCompaction/full.test.ts @@ -184,6 +184,7 @@ describe('FullCompaction', () => { blockRatio: 0.85, reservedContextSize: 50_000, maxCompactionPerTurn: 3, + maxOverflowCompactionAttempts: 3, maxRecentMessages: 3, maxRecentUserMessages: Infinity, maxRecentSizeRatio: 0.2, @@ -2258,6 +2259,7 @@ const alwaysCompactOnce: CompactionStrategy = { reduceCompactOnOverflow: (messages: readonly Message[]) => messages.length, checkAfterStep: true, maxCompactionPerTurn: 1, + maxOverflowCompactionAttempts: 3, }; function missingToolCall(): ToolCall { @@ -2275,6 +2277,7 @@ function testCompactionStrategy(maxSize: number = 1_000): DefaultCompactionStrat blockRatio: 0.85, reservedContextSize: 0, maxCompactionPerTurn: 3, + maxOverflowCompactionAttempts: 3, maxRecentMessages: 10, maxRecentUserMessages: Infinity, maxRecentSizeRatio: 0.2, @@ -2288,6 +2291,7 @@ function overflowOnlyCompactionStrategy(maxSize: number = 14): DefaultCompaction blockRatio: Infinity, reservedContextSize: 0, maxCompactionPerTurn: 3, + maxOverflowCompactionAttempts: 3, maxRecentMessages: 3, maxRecentUserMessages: Infinity, maxRecentSizeRatio: 0.2, diff --git a/packages/agent-core-v2/test/fullCompaction/strategy.test.ts b/packages/agent-core-v2/test/fullCompaction/strategy.test.ts index e6a29eff6..fdcf527a9 100644 --- a/packages/agent-core-v2/test/fullCompaction/strategy.test.ts +++ b/packages/agent-core-v2/test/fullCompaction/strategy.test.ts @@ -139,6 +139,7 @@ describe('DefaultCompactionStrategy', () => { blockRatio: 0.85, reservedContextSize: 50_000, maxCompactionPerTurn: 3, + maxOverflowCompactionAttempts: 3, maxRecentMessages: 3, maxRecentUserMessages: Infinity, maxRecentSizeRatio: 0.2, @@ -158,6 +159,7 @@ function testCompactionStrategy(maxSize: number = 1_000): DefaultCompactionStrat blockRatio: 0.85, reservedContextSize: 0, maxCompactionPerTurn: 3, + maxOverflowCompactionAttempts: 3, maxRecentMessages: 10, maxRecentUserMessages: Infinity, maxRecentSizeRatio: 0.2, @@ -171,6 +173,7 @@ function overflowOnlyCompactionStrategy(maxSize: number = 14): DefaultCompaction blockRatio: Infinity, reservedContextSize: 0, maxCompactionPerTurn: 3, + maxOverflowCompactionAttempts: 3, maxRecentMessages: 3, maxRecentUserMessages: Infinity, maxRecentSizeRatio: 0.2,