From 7d6c70d46b6b308d63099263bc3f3a3fb626d7f5 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 27 May 2026 15:04:03 +0800 Subject: [PATCH 01/15] fix: compaction --- .../agent-core/src/agent/compaction/full.ts | 104 +----------------- .../src/agent/compaction/strategy.ts | 97 ++++++++++++++++ .../src/agent/context/complete-slice.ts | 55 --------- 3 files changed, 100 insertions(+), 156 deletions(-) create mode 100644 packages/agent-core/src/agent/compaction/strategy.ts delete mode 100644 packages/agent-core/src/agent/context/complete-slice.ts diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index 4f20cb26..e432ec17 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -29,109 +29,14 @@ import { import { renderPrompt } from '../../utils/render-prompt'; import { estimateTokens, - estimateTokensForMessage, estimateTokensForMessages, } from '../../utils/tokens'; -import { sliceCompleteMessages } from '../context/complete-slice'; import { project } from '../context/projector'; import compactionInstructionTemplate from './compaction-instruction.md'; -import { DEFAULT_COMPACTION_CONFIG, type CompactionConfig } from './config'; +import { DEFAULT_COMPACTION_CONFIG } from './config'; import { renderMessagesToText } from './render-messages'; import type { CompactionBeginData, CompactionResult } from './types'; - -export interface CompactionStrategy { - shouldCompact(usedSize: number, maxSize: number): boolean; - shouldBlock(usedSize: number, maxSize: number): boolean; - computeCompactCount(messages: readonly Message[], maxSize: number): number; - readonly checkAfterStep: boolean; - readonly maxCompactionPerTurn: number; -} - -export class DefaultCompactionStrategy implements CompactionStrategy { - constructor(protected readonly config: CompactionConfig = DEFAULT_COMPACTION_CONFIG) {} - - shouldCompact(usedSize: number, maxSize: number): boolean { - if (maxSize <= 0) return false; - return ( - usedSize >= maxSize * this.config.triggerRatio || - this.shouldUseReservedContext(maxSize, usedSize) - ); - } - - shouldBlock(usedSize: number, maxSize: number): boolean { - if (maxSize <= 0) return false; - return ( - usedSize >= maxSize * this.config.blockRatio || - this.shouldUseReservedContext(maxSize, usedSize) - ); - } - - private shouldUseReservedContext(maxSize: number, usedSize: number): boolean { - const reservedSize = this.config.reservedContextSize; - return reservedSize > 0 && reservedSize < maxSize && usedSize + reservedSize >= maxSize; - } - - computeCompactCount(messages: readonly Message[], maxSize: number) { - let splitAt = messages.length; - let recentSize = 0; - let userMessageCount = 0; - let onlySeenTrailingUsers = true; - for (let i = messages.length - 1; i >= 0; i--) { - const m1 = messages[i - 1]; - const m2 = messages[i]; - if (m2 === undefined) continue; - const isTrailingAssistantPlaceholder = - onlySeenTrailingUsers && - m2.role === 'assistant' && - m2.content.length === 0 && - m2.toolCalls.length === 0; - if (isTrailingAssistantPlaceholder) { - splitAt = i; - continue; - } - const isTrailingUserMessage = onlySeenTrailingUsers && m2.role === 'user'; - if (!isTrailingUserMessage && messages.length - i >= this.config.maxRecentSteps) break; - - if (m2.role === 'user') { - userMessageCount++; - if (!isTrailingUserMessage && userMessageCount > this.config.maxRecentUserMessages) { - break; - } - } - - recentSize += estimateTokensForMessage(m2); - if (isTrailingUserMessage) { - splitAt = i; - continue; - } - if (recentSize > maxSize * this.config.maxRecentSizeRatio) { - break; - } - const canSplitBeforeMessage = - m1?.role !== m2.role && !(m1?.role === 'user' && m2.role === 'assistant') && m2.role !== 'tool'; - if (canSplitBeforeMessage) { - splitAt = i; - } - if (m2.role !== 'user') { - onlySeenTrailingUsers = false; - } - } - - return splitAt; - } - - get checkAfterStep(): boolean { - return this.config.triggerRatio !== this.config.blockRatio; - } - - get maxCompactionPerTurn(): number { - return this.config.maxCompactionPerTurn; - } -} - -export interface CompactedHistory { - text: string; -} +import { DefaultCompactionStrategy, type CompactedHistory, type CompactionStrategy } from './strategy'; type CompactionTelemetryTrigger = CompactionBeginData['source'] | 'manual-with-prompt' | 'unknown'; @@ -511,10 +416,7 @@ export class FullCompaction { } private computeCompactableCount(history: readonly Message[]): number { - return sliceCompleteMessages( - history, - this.strategy.computeCompactCount(history, this.maxContextSize), - ); + return this.strategy.computeCompactCount(history, this.maxContextSize); } } diff --git a/packages/agent-core/src/agent/compaction/strategy.ts b/packages/agent-core/src/agent/compaction/strategy.ts new file mode 100644 index 00000000..85f251f9 --- /dev/null +++ b/packages/agent-core/src/agent/compaction/strategy.ts @@ -0,0 +1,97 @@ +import type { Message } from "@moonshot-ai/kosong"; +import { estimateTokensForMessage } from "../../utils/tokens"; +import { type CompactionConfig, DEFAULT_COMPACTION_CONFIG } from "./config"; + +export interface CompactionStrategy { + shouldCompact(usedSize: number, maxSize: number): boolean; + shouldBlock(usedSize: number, maxSize: number): boolean; + computeCompactCount(messages: readonly Message[], maxSize: number): number; + readonly checkAfterStep: boolean; + readonly maxCompactionPerTurn: number; +} + +export class DefaultCompactionStrategy implements CompactionStrategy { + constructor(protected readonly config: CompactionConfig = DEFAULT_COMPACTION_CONFIG) {} + + shouldCompact(usedSize: number, maxSize: number): boolean { + if (maxSize <= 0) return false; + return ( + usedSize >= maxSize * this.config.triggerRatio || + this.shouldUseReservedContext(maxSize, usedSize) + ); + } + + shouldBlock(usedSize: number, maxSize: number): boolean { + if (maxSize <= 0) return false; + return ( + usedSize >= maxSize * this.config.blockRatio || + this.shouldUseReservedContext(maxSize, usedSize) + ); + } + + private shouldUseReservedContext(maxSize: number, usedSize: number): boolean { + const reservedSize = this.config.reservedContextSize; + return reservedSize > 0 && reservedSize < maxSize && usedSize + reservedSize >= maxSize; + } + + computeCompactCount(messages: readonly Message[], maxSize: number) { + let splitAt = messages.length; + let recentSize = 0; + let userMessageCount = 0; + let onlySeenTrailingUsers = true; + for (let i = messages.length - 1; i >= 0; i--) { + const m1 = messages[i - 1]; + const m2 = messages[i]; + if (m2 === undefined) continue; + const isTrailingAssistantPlaceholder = + onlySeenTrailingUsers && + m2.role === 'assistant' && + m2.content.length === 0 && + m2.toolCalls.length === 0; + if (isTrailingAssistantPlaceholder) { + splitAt = i; + continue; + } + const isTrailingUserMessage = onlySeenTrailingUsers && m2.role === 'user'; + if (!isTrailingUserMessage && messages.length - i >= this.config.maxRecentSteps) break; + + if (m2.role === 'user') { + userMessageCount++; + if (!isTrailingUserMessage && userMessageCount > this.config.maxRecentUserMessages) { + break; + } + } + + recentSize += estimateTokensForMessage(m2); + if (isTrailingUserMessage) { + splitAt = i; + continue; + } + if (recentSize > maxSize * this.config.maxRecentSizeRatio) { + break; + } + const canSplitBeforeMessage = + m1?.role !== m2.role && !(m1?.role === 'user' && m2.role === 'assistant') && m2.role !== 'tool'; + if (canSplitBeforeMessage) { + splitAt = i; + } + if (m2.role !== 'user') { + onlySeenTrailingUsers = false; + } + } + + return splitAt; + } + + get checkAfterStep(): boolean { + return this.config.triggerRatio !== this.config.blockRatio; + } + + get maxCompactionPerTurn(): number { + return this.config.maxCompactionPerTurn; + } +} + +export interface CompactedHistory { + text: string; +} diff --git a/packages/agent-core/src/agent/context/complete-slice.ts b/packages/agent-core/src/agent/context/complete-slice.ts deleted file mode 100644 index 258cdf90..00000000 --- a/packages/agent-core/src/agent/context/complete-slice.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Message } from '@moonshot-ai/kosong'; - -export function sliceCompleteMessages( - messages: readonly Message[], - requestedEnd: number, -): number { - let normalized = Math.max(0, Math.min(messages.length, requestedEnd)); - - for (let i = 0; i < messages.length; i += 1) { - const message = messages[i]; - if (message?.role !== 'assistant' || message.toolCalls.length === 0) continue; - - const end = findToolExchangeEnd(messages, i); - if (end === undefined) { - if (normalized > i) { - normalized = includePromptForAssistant(messages, i); - } - continue; - } - - if (normalized > i && normalized < end) { - normalized = includePromptForAssistant(messages, i); - } - } - - return normalized; -} - -function findToolExchangeEnd( - messages: readonly Message[], - assistantIndex: number, -): number | undefined { - const assistant = messages[assistantIndex]; - if (assistant?.role !== 'assistant') return undefined; - - const pending = new Set(assistant.toolCalls.map((call) => call.id)); - if (pending.size === 0) return assistantIndex + 1; - - for (let i = assistantIndex + 1; i < messages.length; i += 1) { - const message = messages[i]; - if (message?.role !== 'tool') return undefined; - if (message.toolCallId !== undefined) { - pending.delete(message.toolCallId); - } - if (pending.size === 0) return i + 1; - } - - return undefined; -} - -function includePromptForAssistant(messages: readonly Message[], assistantIndex: number): number { - const previous = messages[assistantIndex - 1]; - if (previous?.role === 'user') return assistantIndex - 1; - return assistantIndex; -} From 97a29a3574c047f97081a7db492ff6744f744236 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 27 May 2026 15:04:35 +0800 Subject: [PATCH 02/15] fix --- packages/agent-core/src/agent/compaction/full.ts | 6 +++++- packages/agent-core/src/agent/compaction/strategy.ts | 4 ---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index e432ec17..3bb55fff 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -36,10 +36,14 @@ import compactionInstructionTemplate from './compaction-instruction.md'; import { DEFAULT_COMPACTION_CONFIG } from './config'; import { renderMessagesToText } from './render-messages'; import type { CompactionBeginData, CompactionResult } from './types'; -import { DefaultCompactionStrategy, type CompactedHistory, type CompactionStrategy } from './strategy'; +import { DefaultCompactionStrategy, type CompactionStrategy } from './strategy'; type CompactionTelemetryTrigger = CompactionBeginData['source'] | 'manual-with-prompt' | 'unknown'; +export interface CompactedHistory { + text: string; +} + export class FullCompaction { protected compactionCountInTurn = 0; protected compacting: { diff --git a/packages/agent-core/src/agent/compaction/strategy.ts b/packages/agent-core/src/agent/compaction/strategy.ts index 85f251f9..6135f363 100644 --- a/packages/agent-core/src/agent/compaction/strategy.ts +++ b/packages/agent-core/src/agent/compaction/strategy.ts @@ -91,7 +91,3 @@ export class DefaultCompactionStrategy implements CompactionStrategy { return this.config.maxCompactionPerTurn; } } - -export interface CompactedHistory { - text: string; -} From d9bbb6e57276fd626a573dff321be73b241ecf84 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 27 May 2026 15:08:51 +0800 Subject: [PATCH 03/15] fix --- .../agent-core/src/agent/compaction/config.ts | 19 ----------------- .../agent-core/src/agent/compaction/full.ts | 3 +-- .../src/agent/compaction/strategy.ts | 21 ++++++++++++++++++- 3 files changed, 21 insertions(+), 22 deletions(-) delete mode 100644 packages/agent-core/src/agent/compaction/config.ts diff --git a/packages/agent-core/src/agent/compaction/config.ts b/packages/agent-core/src/agent/compaction/config.ts deleted file mode 100644 index 50d92f30..00000000 --- a/packages/agent-core/src/agent/compaction/config.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface CompactionConfig { - triggerRatio: number; - blockRatio: number; - reservedContextSize: number; - maxCompactionPerTurn: number; - maxRecentSteps: number; - maxRecentUserMessages: number; - maxRecentSizeRatio: number; -} - -export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = { - triggerRatio: 0.85, - blockRatio: 0.85, // Same as triggerRatio to disable async compaction - reservedContextSize: 50_000, - maxCompactionPerTurn: 3, - maxRecentSteps: 3, - maxRecentUserMessages: Infinity, - maxRecentSizeRatio: 0.2, -}; diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index 3bb55fff..c6005456 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -33,10 +33,9 @@ import { } from '../../utils/tokens'; import { project } from '../context/projector'; import compactionInstructionTemplate from './compaction-instruction.md'; -import { DEFAULT_COMPACTION_CONFIG } from './config'; import { renderMessagesToText } from './render-messages'; import type { CompactionBeginData, CompactionResult } from './types'; -import { DefaultCompactionStrategy, type CompactionStrategy } from './strategy'; +import { DEFAULT_COMPACTION_CONFIG, DefaultCompactionStrategy, type CompactionStrategy } from './strategy'; type CompactionTelemetryTrigger = CompactionBeginData['source'] | 'manual-with-prompt' | 'unknown'; diff --git a/packages/agent-core/src/agent/compaction/strategy.ts b/packages/agent-core/src/agent/compaction/strategy.ts index 6135f363..73b87eef 100644 --- a/packages/agent-core/src/agent/compaction/strategy.ts +++ b/packages/agent-core/src/agent/compaction/strategy.ts @@ -1,6 +1,25 @@ import type { Message } from "@moonshot-ai/kosong"; import { estimateTokensForMessage } from "../../utils/tokens"; -import { type CompactionConfig, DEFAULT_COMPACTION_CONFIG } from "./config"; + +export interface CompactionConfig { + triggerRatio: number; + blockRatio: number; + reservedContextSize: number; + maxCompactionPerTurn: number; + maxRecentSteps: number; + maxRecentUserMessages: number; + maxRecentSizeRatio: number; +} + +export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = { + triggerRatio: 0.85, + blockRatio: 0.85, // Same as triggerRatio to disable async compaction + reservedContextSize: 50_000, + maxCompactionPerTurn: 3, + maxRecentSteps: 3, + maxRecentUserMessages: Infinity, + maxRecentSizeRatio: 0.2, +}; export interface CompactionStrategy { shouldCompact(usedSize: number, maxSize: number): boolean; From 1654d2d524846225b41022aa9f1e627b012d5972 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 27 May 2026 17:21:59 +0800 Subject: [PATCH 04/15] fix --- .../agent-core/src/agent/compaction/full.ts | 201 +++++++----------- .../src/agent/compaction/strategy.ts | 120 ++++++----- 2 files changed, 137 insertions(+), 184 deletions(-) diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index c6005456..1fe914e4 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -12,20 +12,16 @@ import { type GenerateResult, type Message, type TokenUsage, + APIContextOverflowError, } from '@moonshot-ai/kosong'; import type { Agent } from '..'; import { isAbortError } from '../../loop/errors'; import { - DEFAULT_MAX_RETRY_ATTEMPTS, retryBackoffDelays, sleepForRetry, } from '../../loop/retry'; import type { TelemetryPropertyValue } from '../../telemetry'; -import { - applyCompletionBudget, - resolveCompletionBudget, -} from '../../utils/completion-budget'; import { renderPrompt } from '../../utils/render-prompt'; import { estimateTokens, @@ -43,6 +39,8 @@ export interface CompactedHistory { text: string; } +export const MAX_COMPACTION_RETRY_ATTEMPTS = 5; + export class FullCompaction { protected compactionCountInTurn = 0; protected compacting: { @@ -60,18 +58,25 @@ export class FullCompaction { ) { this.strategy = strategy ?? - new DefaultCompactionStrategy({ - ...DEFAULT_COMPACTION_CONFIG, - reservedContextSize: - agent.providerManager?.config.loopControl?.reservedContextSize ?? - DEFAULT_COMPACTION_CONFIG.reservedContextSize, - }); + new DefaultCompactionStrategy( + () => agent.config.modelCapabilities.max_context_tokens, + { + ...DEFAULT_COMPACTION_CONFIG, + reservedContextSize: + agent.providerManager?.config.loopControl?.reservedContextSize ?? + DEFAULT_COMPACTION_CONFIG.reservedContextSize, + } + ); } get isCompacting(): boolean { return this.compacting !== null; } + get compactedHistory(): readonly CompactedHistory[] { + return this._compactedHistory; + } + begin(data: Readonly): void { this.agent.records.logRecord({ type: 'full_compaction.begin', @@ -157,18 +162,6 @@ export class FullCompaction { return this.agent.context.tokenCountWithPending; } - private get maxContextSize() { - return this.agent.config.modelCapabilities.max_context_tokens; - } - - private get shouldCompact(): boolean { - return this.strategy.shouldCompact(this.tokenCountWithPending, this.maxContextSize); - } - - private get shouldBlock(): boolean { - return this.strategy.shouldBlock(this.tokenCountWithPending, this.maxContextSize); - } - resetForTurn(): void { this.compactionCountInTurn = 0; } @@ -182,7 +175,7 @@ export class FullCompaction { async beforeStep(signal: AbortSignal): Promise { this.checkAutoCompaction(); - if (this.shouldBlock) { + if (this.strategy.shouldBlock(this.tokenCountWithPending)) { await this.block(signal); } } @@ -196,7 +189,7 @@ export class FullCompaction { private checkAutoCompaction(throwOnLimit: boolean = true): boolean { if (this.compacting) return true; - if (!this.shouldCompact) return false; + if (!this.strategy.shouldCompact(this.tokenCountWithPending)) return false; return this.beginAutoCompaction(throwOnLimit); } @@ -212,16 +205,7 @@ export class FullCompaction { } return false; } - const history = this.agent.context.history; - const compactedCount = this.computeCompactableCount(history); - if (compactedCount === 0) return false; - if ( - this.maxContextSize > 0 && - estimateTokensForMessages(project(history.slice(compactedCount))) >= this.maxContextSize - ) { - return false; - } - this.agent.fullCompaction.begin({ source: 'auto', instruction: undefined }); + this.begin({ source: 'auto', instruction: undefined }); return true; } @@ -246,42 +230,59 @@ export class FullCompaction { data: Readonly, ): Promise { const startedAt = Date.now(); - let tokensBeforeForError = 0; - let retryCountForTelemetry = 0; + const originalHistory = [...this.agent.context.history]; + const tokensBefore = this.agent.context.tokenCount; + let retryCount = 0; try { - const originalHistory = [...this.agent.context.history]; - const tokensBefore = this.agent.context.tokenCount; - tokensBeforeForError = tokensBefore; - const compactedCount = this.computeCompactableCount(originalHistory); - if (compactedCount === 0) { - this.markCanceled(); - return undefined; - } - signal.throwIfAborted(); + let compactedCount = this.strategy.computeCompactCount(originalHistory); + await this.triggerPreCompactHook(data, tokensBefore, signal); - signal.throwIfAborted(); const model = this.agent.config.model; - const messages = [ - ...project(originalHistory.slice(0, compactedCount)), - { - role: 'user', - content: [ - { - type: 'text', - text: COMPACTION_INSTRUCTION(data.instruction), - }, - ], - toolCalls: [], - } satisfies Message, - ]; - const { response, retryCount, summary } = await this.generateCompactionResponse({ - messages, - signal, - onRetry: (count) => { - retryCountForTelemetry = count; - }, - }); + + const delays = retryBackoffDelays(MAX_COMPACTION_RETRY_ATTEMPTS); + let response: GenerateResult; + while (true) { + const messagesToCompact = originalHistory.slice(0, compactedCount); + const messages = [ + ...messagesToCompact, + { + role: 'user', + content: [ + { + type: 'text', + text: COMPACTION_INSTRUCTION(data.instruction), + }, + ], + toolCalls: [], + } satisfies Message, + ]; + try { + response = await this.agent.generate( + this.agent.config.provider, + this.agent.config.systemPrompt, + [...this.agent.tools.loopTools], + messages, + undefined, + { signal }, + ); + break; + } catch (error) { + retryCount += 1; + if (retryCount >= MAX_COMPACTION_RETRY_ATTEMPTS) { + throw error; + } + if (error instanceof APIContextOverflowError) { + compactedCount = this.strategy.reduceCompactOnOverflow(messagesToCompact); + } + if (!isRetryableGenerateError(error)) { + throw error; + } + await sleepForRetry(delays[retryCount - 1]!, signal); + } + } + const summary = extractCompactionSummary(response); + if (response.usage !== null) { this.agent.usage.record(model, response.usage); } @@ -325,76 +326,21 @@ export class FullCompaction { }); this.agent.telemetry.track('compaction_failed', { trigger_type: compactionTelemetryTrigger(data.source, data.instruction), - before_tokens: tokensBeforeForError, + before_tokens: tokensBefore, duration_ms: Date.now() - startedAt, - retry_count: retryCountForTelemetry, + retry_count: retryCount, error_type: error instanceof Error ? error.name : 'Unknown', }); } } } - private async generateCompactionResponse({ - messages, - signal, - onRetry, - }: { - readonly messages: Message[]; - readonly signal: AbortSignal; - readonly onRetry?: ((retryCount: number) => void) | undefined; - }): Promise<{ - readonly response: GenerateResult; - readonly summary: string; - readonly retryCount: number; - }> { - const maxAttempts = - this.agent.providerManager?.config.loopControl?.maxRetriesPerStep ?? - DEFAULT_MAX_RETRY_ATTEMPTS; - const delays = retryBackoffDelays(maxAttempts); - let retryCount = 0; - - const completionBudget = resolveCompletionBudget({ - reservedContextSize: - this.agent.providerManager?.config.loopControl?.reservedContextSize, - }); - const effectiveProvider = applyCompletionBudget({ - provider: this.agent.config.provider, - budget: completionBudget, - capability: this.agent.config.modelCapabilities, - }); - - for (let attempt = 1; ; attempt += 1) { - try { - const response = await this.agent.generate( - effectiveProvider, - this.agent.config.systemPrompt, - [...this.agent.tools.loopTools], - messages, - undefined, - { signal }, - ); - const summary = extractCompactionSummary(response); - return { response, summary, retryCount }; - } catch (error) { - if (attempt >= maxAttempts || !isRetryableGenerateError(error)) { - throw error; - } - retryCount += 1; - onRetry?.(retryCount); - await sleepForRetry(delays[attempt - 1] ?? 0, signal); - } - } - } - - get compactedHistory(): readonly CompactedHistory[] { - return this._compactedHistory; - } - private async triggerPreCompactHook( data: Readonly, tokenCount: number, signal: AbortSignal, ): Promise { + signal.throwIfAborted(); await this.agent.hooks?.trigger('PreCompact', { matcherValue: data.source, signal, @@ -403,6 +349,7 @@ export class FullCompaction { tokenCount, }, }); + signal.throwIfAborted(); } private triggerPostCompactHook( @@ -417,10 +364,6 @@ export class FullCompaction { }, }); } - - private computeCompactableCount(history: readonly Message[]): number { - return this.strategy.computeCompactCount(history, this.maxContextSize); - } } function extractCompactionSummary(response: GenerateResult): string { diff --git a/packages/agent-core/src/agent/compaction/strategy.ts b/packages/agent-core/src/agent/compaction/strategy.ts index 73b87eef..48498cad 100644 --- a/packages/agent-core/src/agent/compaction/strategy.ts +++ b/packages/agent-core/src/agent/compaction/strategy.ts @@ -6,7 +6,7 @@ export interface CompactionConfig { blockRatio: number; reservedContextSize: number; maxCompactionPerTurn: number; - maxRecentSteps: number; + maxRecentMessages: number; maxRecentUserMessages: number; maxRecentSizeRatio: number; } @@ -16,90 +16,96 @@ export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = { blockRatio: 0.85, // Same as triggerRatio to disable async compaction reservedContextSize: 50_000, maxCompactionPerTurn: 3, - maxRecentSteps: 3, + maxRecentMessages: 4, maxRecentUserMessages: Infinity, maxRecentSizeRatio: 0.2, }; export interface CompactionStrategy { - shouldCompact(usedSize: number, maxSize: number): boolean; - shouldBlock(usedSize: number, maxSize: number): boolean; - computeCompactCount(messages: readonly Message[], maxSize: number): number; + shouldCompact(usedSize: number): boolean; + shouldBlock(usedSize: number): boolean; + computeCompactCount(messages: readonly Message[]): number; + reduceCompactOnOverflow(messages: readonly Message[]): number; readonly checkAfterStep: boolean; readonly maxCompactionPerTurn: number; } export class DefaultCompactionStrategy implements CompactionStrategy { - constructor(protected readonly config: CompactionConfig = DEFAULT_COMPACTION_CONFIG) {} + constructor( + protected readonly maxSizeProvider: () => number, + protected readonly config: CompactionConfig = DEFAULT_COMPACTION_CONFIG + ) { } - shouldCompact(usedSize: number, maxSize: number): boolean { - if (maxSize <= 0) return false; + protected get maxSize(): number { + return this.maxSizeProvider(); + } + + shouldCompact(usedSize: number): boolean { + if (this.maxSize <= 0) return false; return ( - usedSize >= maxSize * this.config.triggerRatio || - this.shouldUseReservedContext(maxSize, usedSize) + usedSize >= this.maxSize * this.config.triggerRatio || + this.shouldUseReservedContext(usedSize) ); } - shouldBlock(usedSize: number, maxSize: number): boolean { - if (maxSize <= 0) return false; + shouldBlock(usedSize: number): boolean { + if (this.maxSize <= 0) return false; return ( - usedSize >= maxSize * this.config.blockRatio || - this.shouldUseReservedContext(maxSize, usedSize) + usedSize >= this.maxSize * this.config.blockRatio || + this.shouldUseReservedContext(usedSize) ); } - private shouldUseReservedContext(maxSize: number, usedSize: number): boolean { + private shouldUseReservedContext(usedSize: number): boolean { const reservedSize = this.config.reservedContextSize; - return reservedSize > 0 && reservedSize < maxSize && usedSize + reservedSize >= maxSize; + return reservedSize > 0 && reservedSize < this.maxSize && usedSize + reservedSize >= this.maxSize; } - computeCompactCount(messages: readonly Message[], maxSize: number) { - let splitAt = messages.length; + computeCompactCount(messages: readonly Message[]): number { + // Return value: N messages to be compacted + // LLM Input: messages.slice(0, N) + [user:instruction] + // Preserved recent messages: messages.slice(N) + // + // Rules (in order of precedence): + // 1. messages[N-1] must not be a user message or assistant message with tool calls + // 2. At least one recent message must be preserved + // 3. At most maxRecentMessages recent messages should be preserved + // 4. At most maxRecentUserMessages recent user messages should be preserved + // 5. At most maxRecentSizeRatio * maxSize recent messages should be preserved + // 6. N should be as small as possible + + let recentMessages = 1; + let recentUserMessages = 0; let recentSize = 0; - let userMessageCount = 0; - let onlySeenTrailingUsers = true; - for (let i = messages.length - 1; i >= 0; i--) { - const m1 = messages[i - 1]; - const m2 = messages[i]; - if (m2 === undefined) continue; - const isTrailingAssistantPlaceholder = - onlySeenTrailingUsers && - m2.role === 'assistant' && - m2.content.length === 0 && - m2.toolCalls.length === 0; - if (isTrailingAssistantPlaceholder) { - splitAt = i; - continue; - } - const isTrailingUserMessage = onlySeenTrailingUsers && m2.role === 'user'; - if (!isTrailingUserMessage && messages.length - i >= this.config.maxRecentSteps) break; + for (; recentMessages < messages.length; recentMessages++) { + const m1 = messages[messages.length - recentMessages - 1]!; + const m2 = messages[messages.length - recentMessages]!; + + recentMessages++; if (m2.role === 'user') { - userMessageCount++; - if (!isTrailingUserMessage && userMessageCount > this.config.maxRecentUserMessages) { - break; - } + recentUserMessages++; } - recentSize += estimateTokensForMessage(m2); - if (isTrailingUserMessage) { - splitAt = i; - continue; - } - if (recentSize > maxSize * this.config.maxRecentSizeRatio) { - break; - } - const canSplitBeforeMessage = - m1?.role !== m2.role && !(m1?.role === 'user' && m2.role === 'assistant') && m2.role !== 'tool'; - if (canSplitBeforeMessage) { - splitAt = i; - } - if (m2.role !== 'user') { - onlySeenTrailingUsers = false; + + const reachesMax = recentMessages >= this.config.maxRecentMessages + || recentUserMessages >= this.config.maxRecentUserMessages + || recentSize >= this.maxSize * this.config.maxRecentSizeRatio; + if (!cannotSplitAfter(m1) && reachesMax) { + return messages.length - recentMessages; } } - return splitAt; + throw new Error('Unable to compact messages: all messages are too recent or cannot be split'); + } + + reduceCompactOnOverflow(messages: readonly Message[]): number { + for (let i = messages.length - 2; i > 0; i--) { + if (!cannotSplitAfter(messages[i]!)) { + return i + 1; + } + } + return messages.length; } get checkAfterStep(): boolean { @@ -110,3 +116,7 @@ export class DefaultCompactionStrategy implements CompactionStrategy { return this.config.maxCompactionPerTurn; } } + +function cannotSplitAfter(message: Message): boolean { + return message.role === 'user' || (message.role === 'assistant' && message.toolCalls.length > 0); +} From ed22e9d19176fe6010548c07595b24dcb4ba8ed4 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 27 May 2026 19:51:22 +0800 Subject: [PATCH 05/15] fix --- .changeset/fix-compaction-edge-cases.md | 6 + apps/kimi-code/src/tui/kimi-tui.ts | 8 +- .../agent-core/src/agent/compaction/full.ts | 41 ++-- .../agent-core/src/agent/compaction/index.ts | 2 +- .../src/agent/compaction/strategy.ts | 17 +- packages/agent-core/src/errors/codes.ts | 7 + .../agent-core/test/agent/compaction.test.ts | 208 ++++++------------ .../agent-core/test/agent/context.test.ts | 51 ----- packages/node-sdk/test/session-cancel.test.ts | 20 +- .../session-plan-compact-usage-resume.test.ts | 14 +- 10 files changed, 138 insertions(+), 236 deletions(-) create mode 100644 .changeset/fix-compaction-edge-cases.md diff --git a/.changeset/fix-compaction-edge-cases.md b/.changeset/fix-compaction-edge-cases.md new file mode 100644 index 00000000..001dcb95 --- /dev/null +++ b/.changeset/fix-compaction-edge-cases.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix compaction to handle edge cases where no messages are compactable and improve retry logic. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 0e71ef4e..73051835 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -5594,7 +5594,13 @@ export class KimiTUI { } const customInstruction = args.trim() || undefined; - await session.compact({ instruction: customInstruction }); + try { + await session.compact({ instruction: customInstruction }); + } catch (error) { + const code = (error as { code?: string } | null)?.code; + const message = error instanceof Error ? error.message : String(error); + this.showError(code !== undefined ? `[${code}] ${message}` : message); + } } // Handles the /init command. diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index 1fe914e4..2ccc3cb5 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -78,10 +78,6 @@ export class FullCompaction { } begin(data: Readonly): void { - this.agent.records.logRecord({ - type: 'full_compaction.begin', - ...data, - }); if (this.compacting) return; if (data.source === 'manual') { this.compactionCountInTurn = 0; @@ -89,12 +85,23 @@ export class FullCompaction { this.compactionCountInTurn += 1; } if (this.compactionCountInTurn > this.strategy.maxCompactionPerTurn) return; + const compactedCount = this.strategy.computeCompactCount(this.agent.context.history); + if (compactedCount === 0) { + throw new KimiError(ErrorCodes.COMPACTION_UNABLE, 'No compactable prefix in current history.'); + } + this.agent.records.logRecord({ + type: 'full_compaction.begin', + ...data, + }); if (!this.agent.records.restoring) { - this.startCompactionWorker(data); + this.startCompactionWorker(data, compactedCount); } } - private startCompactionWorker(data: Readonly): void { + private startCompactionWorker( + data: Readonly, + compactedCount: number, + ): void { const abortController = new AbortController(); this.agent.emitEvent({ type: 'compaction.started', @@ -108,7 +115,7 @@ export class FullCompaction { promise: Promise.resolve(), }; this.compacting = active; - active.promise = this.compactionWorker(abortController.signal, data); + active.promise = this.compactionWorker(abortController.signal, data, compactedCount); } cancel(): void { @@ -206,7 +213,7 @@ export class FullCompaction { return false; } this.begin({ source: 'auto', instruction: undefined }); - return true; + return this.compacting !== null; } private async block(signal: AbortSignal): Promise { @@ -228,13 +235,14 @@ export class FullCompaction { private async compactionWorker( signal: AbortSignal, data: Readonly, + initialCompactedCount: number, ): Promise { const startedAt = Date.now(); const originalHistory = [...this.agent.context.history]; const tokensBefore = this.agent.context.tokenCount; let retryCount = 0; try { - let compactedCount = this.strategy.computeCompactCount(originalHistory); + let compactedCount = initialCompactedCount; await this.triggerPreCompactHook(data, tokensBefore, signal); @@ -242,10 +250,11 @@ export class FullCompaction { const delays = retryBackoffDelays(MAX_COMPACTION_RETRY_ATTEMPTS); let response: GenerateResult; + let summary: string; while (true) { const messagesToCompact = originalHistory.slice(0, compactedCount); const messages = [ - ...messagesToCompact, + ...project(messagesToCompact), { role: 'user', content: [ @@ -266,22 +275,22 @@ export class FullCompaction { undefined, { signal }, ); + summary = extractCompactionSummary(response); break; } catch (error) { - retryCount += 1; - if (retryCount >= MAX_COMPACTION_RETRY_ATTEMPTS) { - throw error; - } if (error instanceof APIContextOverflowError) { compactedCount = this.strategy.reduceCompactOnOverflow(messagesToCompact); } if (!isRetryableGenerateError(error)) { throw error; } - await sleepForRetry(delays[retryCount - 1]!, signal); + if (retryCount + 1 >= MAX_COMPACTION_RETRY_ATTEMPTS) { + throw error; + } + await sleepForRetry(delays[retryCount]!, signal); + retryCount += 1; } } - const summary = extractCompactionSummary(response); if (response.usage !== null) { this.agent.usage.record(model, response.usage); diff --git a/packages/agent-core/src/agent/compaction/index.ts b/packages/agent-core/src/agent/compaction/index.ts index d8da8bbd..876ee14e 100644 --- a/packages/agent-core/src/agent/compaction/index.ts +++ b/packages/agent-core/src/agent/compaction/index.ts @@ -1,3 +1,3 @@ export * from './full'; -export * from './config'; +export * from './strategy'; export * from './types'; diff --git a/packages/agent-core/src/agent/compaction/strategy.ts b/packages/agent-core/src/agent/compaction/strategy.ts index 48498cad..8b5cb63f 100644 --- a/packages/agent-core/src/agent/compaction/strategy.ts +++ b/packages/agent-core/src/agent/compaction/strategy.ts @@ -62,7 +62,7 @@ export class DefaultCompactionStrategy implements CompactionStrategy { } computeCompactCount(messages: readonly Message[]): number { - // Return value: N messages to be compacted + // Return value: N messages to be compacted (0 means no compaction possible) // LLM Input: messages.slice(0, N) + [user:instruction] // Preserved recent messages: messages.slice(N) // @@ -77,26 +77,33 @@ export class DefaultCompactionStrategy implements CompactionStrategy { let recentMessages = 1; let recentUserMessages = 0; let recentSize = 0; + let bestN: number | undefined; for (; recentMessages < messages.length; recentMessages++) { const m1 = messages[messages.length - recentMessages - 1]!; const m2 = messages[messages.length - recentMessages]!; - recentMessages++; if (m2.role === 'user') { recentUserMessages++; } recentSize += estimateTokensForMessage(m2); + if (!cannotSplitAfter(m1)) { + bestN = messages.length - recentMessages; + } + const reachesMax = recentMessages >= this.config.maxRecentMessages || recentUserMessages >= this.config.maxRecentUserMessages || recentSize >= this.maxSize * this.config.maxRecentSizeRatio; - if (!cannotSplitAfter(m1) && reachesMax) { - return messages.length - recentMessages; + if (reachesMax && bestN !== undefined) { + break; } } - throw new Error('Unable to compact messages: all messages are too recent or cannot be split'); + if (bestN !== undefined) return bestN; + const last = messages.at(-1); + if (last && !cannotSplitAfter(last)) return messages.length; + return 0; } reduceCompactOnOverflow(messages: readonly Message[]): number { diff --git a/packages/agent-core/src/errors/codes.ts b/packages/agent-core/src/errors/codes.ts index 2583a8fa..4dbf16cb 100644 --- a/packages/agent-core/src/errors/codes.ts +++ b/packages/agent-core/src/errors/codes.ts @@ -51,6 +51,7 @@ export const ErrorCodes = { RECORDS_WRITE_FAILED: 'records.write_failed', COMPACTION_FAILED: 'compaction.failed', + COMPACTION_UNABLE: 'compaction.unable', BACKGROUND_TASK_ID_EMPTY: 'background.task_id_empty', MCP_SERVER_NOT_FOUND: 'mcp.server_not_found', @@ -304,6 +305,12 @@ export const KIMI_ERROR_INFO = { public: true, action: 'Inspect logs and consider increasing compaction limits.', }, + 'compaction.unable': { + title: 'Unable to compact', + retryable: false, + public: true, + action: 'The current history has no prefix that can be compacted (e.g. only a pending user message). Start a new turn or session instead.', + }, 'background.task_id_empty': { title: 'Background task id is empty', diff --git a/packages/agent-core/test/agent/compaction.test.ts b/packages/agent-core/test/agent/compaction.test.ts index 8b518981..0df8cba7 100644 --- a/packages/agent-core/test/agent/compaction.test.ts +++ b/packages/agent-core/test/agent/compaction.test.ts @@ -46,7 +46,7 @@ describe('Agent compaction', () => { textMessage('user', `pending user ${'x'.repeat(1_200)}`), ]; - expect(strategy.computeCompactCount(messages, 1_000)).toBe(2); + expect(strategy.computeCompactCount(messages)).toBe(2); }); it('keeps consecutive trailing user messages as recent', () => { @@ -58,10 +58,10 @@ describe('Agent compaction', () => { textMessage('user', `pending user two ${'x'.repeat(1_200)}`), ]; - expect(strategy.computeCompactCount(messages, 1_000)).toBe(2); + expect(strategy.computeCompactCount(messages)).toBe(2); }); - it('does not keep an oversized completed exchange as recent', () => { + it('compacts the prefix when the trailing exchange itself is oversized', () => { const strategy = testCompactionStrategy(); const messages = [ textMessage('user', 'old user'), @@ -70,31 +70,31 @@ describe('Agent compaction', () => { textMessage('assistant', `recent assistant ${'x'.repeat(1_200)}`), ]; - expect(strategy.computeCompactCount(messages, 1_000)).toBe(messages.length); + expect(strategy.computeCompactCount(messages)).toBe(2); }); it('reserves response context by default before the ratio threshold is reached', () => { - const strategy = new DefaultCompactionStrategy(); + const strategy = new DefaultCompactionStrategy(() => 256_000); - expect(strategy.shouldCompact(210_000, 256_000)).toBe(true); - expect(strategy.shouldBlock(210_000, 256_000)).toBe(true); + expect(strategy.shouldCompact(210_000)).toBe(true); + expect(strategy.shouldBlock(210_000)).toBe(true); }); it('ignores reserved context when the reserve is not smaller than the model window', () => { - const strategy = new DefaultCompactionStrategy({ + const strategy = new DefaultCompactionStrategy(() => 32_000, { triggerRatio: 0.85, blockRatio: 0.85, reservedContextSize: 50_000, maxCompactionPerTurn: 3, - maxRecentSteps: 3, + maxRecentMessages: 3, maxRecentUserMessages: Infinity, maxRecentSizeRatio: 0.2, }); - expect(strategy.shouldCompact(1, 32_000)).toBe(false); - expect(strategy.shouldBlock(1, 32_000)).toBe(false); - expect(strategy.shouldCompact(28_000, 32_000)).toBe(true); - expect(strategy.shouldBlock(28_000, 32_000)).toBe(true); + expect(strategy.shouldCompact(1)).toBe(false); + expect(strategy.shouldBlock(1)).toBe(false); + expect(strategy.shouldCompact(28_000)).toBe(true); + expect(strategy.shouldBlock(28_000)).toBe(true); }); it('runs manual compaction and applies the compacted context', async () => { @@ -123,12 +123,12 @@ describe('Agent compaction', () => { [wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "recent user three" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "