From b1cdaf4c6cdc2920be6763e17094d75830ae2fa5 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 8 Jul 2026 18:50:03 +0800 Subject: [PATCH] fix(agent-core-v2): align compaction empty retry --- .../fullCompaction/fullCompactionService.ts | 59 ++++++++++++++----- .../test/fullCompaction/full.test.ts | 29 +++++---- 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index c2895752b..702cd85b2 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -19,6 +19,7 @@ import { APIContextOverflowError, APIEmptyResponseError, APIStatusError, + isRetryableGenerateError, } from '#/app/llmProtocol/errors'; import { createUserMessage, type Message } from '#/app/llmProtocol/message'; import { type TokenUsage } from '#/app/llmProtocol/usage'; @@ -396,7 +397,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull let retryCount = 0; try { - let compactedCount = Math.min(initialCompactedCount, originalHistory.length); + const compactedCount = Math.min(initialCompactedCount, originalHistory.length); const signal = active.abortController.signal; signal.throwIfAborted(); @@ -420,8 +421,11 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull const delays = retryBackoffDelays(MAX_COMPACTION_RETRY_ATTEMPTS); let attempt: CompactionAttemptResult | undefined; + let historyForModel = originalHistory.slice(0, compactedCount); + let droppedCount = 0; + let emptyOrTruncatedShrinkCount = 0; while (true) { - const messagesToCompact = originalHistory.slice(0, compactedCount); + const messagesToCompact = historyForModel; // Raw context slice — `llmRequester` projects every request once; // projecting here too would run micro-compaction on shifted indices. const messages: Message[] = [...messagesToCompact, createUserMessage(instruction)]; @@ -434,10 +438,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull maxOutputSize: compactionMaxOutputSize, source: { type: 'operation', requestKind: 'full_compaction' }, retry: { - maxAttempts: MAX_COMPACTION_RETRY_ATTEMPTS, - onRetry: () => { - retryCount += 1; - }, + maxAttempts: 1, }, }, undefined, @@ -446,19 +447,33 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull ); break; } catch (error) { - if ( - this.shouldRecoverFromCompactionOverflow(error, messages) || - error instanceof CompactionTruncatedError || - error instanceof APIEmptyResponseError - ) { + if (this.shouldRecoverFromCompactionOverflow(error, messages)) { 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) { + if (reduced >= messagesToCompact.length) { throw error; } - compactedCount = reduced; - } else { + droppedCount += messagesToCompact.length - reduced; + historyForModel = messagesToCompact.slice(0, reduced); + retryCount = 0; + continue; + } + if ( + (error instanceof CompactionTruncatedError || error instanceof APIEmptyResponseError) && + messagesToCompact.length > 1 + ) { + emptyOrTruncatedShrinkCount += 1; + if (emptyOrTruncatedShrinkCount > MAX_COMPACTION_RETRY_ATTEMPTS) { + throw error; + } + const reduced = dropOldestMessageAndLeadingToolResults(messagesToCompact); + droppedCount += messagesToCompact.length - reduced.length; + historyForModel = reduced; + retryCount = 0; + continue; + } + if (!isRetryableGenerateError(error)) { throw error; } if (retryCount + 1 >= MAX_COMPACTION_RETRY_ATTEMPTS) { @@ -489,6 +504,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull contextSummary: buildCompactionSummaryText(summary), compactedCount, tokensBefore, + droppedCount: droppedCount === 0 ? undefined : droppedCount, }); this.telemetry.track('compaction_finished', { @@ -596,6 +612,21 @@ function historySafeToCompact( return current.slice(original.length).every(isRealUserInput); } +function dropOldestMessageAndLeadingToolResults( + messages: readonly T[], +): T[] { + if (messages.length <= 1) return messages.slice(); + return dropLeadingToolResults(messages.slice(1)); +} + +function dropLeadingToolResults(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 { diff --git a/packages/agent-core-v2/test/fullCompaction/full.test.ts b/packages/agent-core-v2/test/fullCompaction/full.test.ts index cc3fcc430..6790a2232 100644 --- a/packages/agent-core-v2/test/fullCompaction/full.test.ts +++ b/packages/agent-core-v2/test/fullCompaction/full.test.ts @@ -16,6 +16,7 @@ import { makeHookRunner } from '../externalHooks/runner-stub'; import type { IExternalHooksRunnerService } from '#/app/externalHooksRunner/externalHooksRunner'; import { MASTER_ENV } from '#/app/flag/flagService'; import { microCompactionFlag } from '#/agent/microCompaction/flag'; +import { COMPACTION_SUMMARY_PREFIX } from '#/agent/contextMemory/compactionHandoff'; import { estimateTokensForMessages } from '#/_base/utils/tokens'; import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs'; import type { TestAgentContext, TestAgentOptions, TestAgentServiceOverride } from '../harness'; @@ -636,23 +637,26 @@ describe('FullCompaction', () => { await completed; expect(attempts).toBe(3); - // Each empty summary shrinks the compacted prefix before retrying, so the - // recovered summary compacts only the older exchange and leaves the recent - // one in history. + // Empty summaries are retried without shrinking the history; the recovered + // summary replaces the whole history with the real user messages plus the + // prefixed summary. expect(ctx.compactHistory()).toEqual([ - { role: 'assistant', text: 'Recovered compacted summary.' }, + { role: 'user', text: 'old user one' }, { role: 'user', text: 'recent user two' }, - { role: 'assistant', text: 'recent assistant two' }, + { role: 'user', text: `${COMPACTION_SUMMARY_PREFIX}\nRecovered compacted summary.` }, ]); expect( ctx.allEvents.filter((event) => event.event === 'compaction.completed'), ).toEqual([ expect.objectContaining({ args: expect.objectContaining({ - result: expect.objectContaining({ summary: 'Recovered compacted summary.' }), + result: expect.objectContaining({ + summary: expect.stringContaining('Recovered compacted summary.'), + }), }), }), ]); + vi.useRealTimers(); await ctx.expectResumeMatches(); }); @@ -692,13 +696,14 @@ describe('FullCompaction', () => { await completed; expect(inputs).toHaveLength(2); - // The retry compacts a strictly smaller prefix than the first attempt. + // The retry sends a strictly smaller input than the first attempt. expect(inputs[1]!.length).toBeLessThan(inputs[0]!.length); expect(ctx.compactHistory()).toEqual([ - { role: 'assistant', text: 'Recovered compacted summary.' }, + { role: 'user', text: 'old user one' }, { role: 'user', text: 'recent user two' }, - { role: 'assistant', text: 'recent assistant two' }, + { role: 'user', text: `${COMPACTION_SUMMARY_PREFIX}\nRecovered compacted summary.` }, ]); + vi.useRealTimers(); await ctx.expectResumeMatches(); }); @@ -771,8 +776,10 @@ describe('FullCompaction', () => { await vi.advanceTimersByTimeAsync(60_000); await failed; - // MAX_COMPACTION_RETRY_ATTEMPTS attempts, with prefix reduction between them. - expect(inputs).toHaveLength(5); + // Each empty/think-only response drops the oldest item and resets the retry + // counter; once only one item remains, MAX_COMPACTION_RETRY_ATTEMPTS more + // retries run before failing. 3 drops + 5 retries = 8 generate calls. + expect(inputs).toHaveLength(8); expect(inputs[1]!.length).toBeLessThan(inputs[0]!.length); expect(records).toContainEqual({ event: 'compaction_failed',