fix(agent-core-v2): align compaction empty retry

This commit is contained in:
_Kerman 2026-07-08 18:50:03 +08:00
parent 61131840c3
commit b1cdaf4c6c
2 changed files with 63 additions and 25 deletions

View file

@ -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<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 {

View file

@ -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',