From 89ea8959eb9419d04e63645b4d89ca0e33f20d98 Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 25 May 2026 14:44:22 +0800 Subject: [PATCH] fix: retry empty compaction summaries (#12) Co-authored-by: liruifengv --- .changeset/retry-empty-compaction-summary.md | 6 +++ .../agent-core/src/agent/compaction/full.ts | 32 +++++++++----- .../agent-core/test/agent/compaction.test.ts | 42 +++++++++++++++++++ 3 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 .changeset/retry-empty-compaction-summary.md diff --git a/.changeset/retry-empty-compaction-summary.md b/.changeset/retry-empty-compaction-summary.md new file mode 100644 index 000000000..aa01beeea --- /dev/null +++ b/.changeset/retry-empty-compaction-summary.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Retry compaction responses that do not contain a summary before updating conversation history. diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index 80f23f2bd..b4b694294 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -369,7 +369,7 @@ export class FullCompaction { toolCalls: [], } satisfies Message, ]; - const { response, retryCount } = await this.generateCompactionResponse({ + const { response, retryCount, summary } = await this.generateCompactionResponse({ messages, signal, onRetry: (count) => { @@ -380,13 +380,6 @@ export class FullCompaction { this.agent.usage.record(model, response.usage); } - const summary = - typeof response.message.content === 'string' - ? response.message.content - : response.message.content - .map((part) => (part.type === 'text' ? part.text : '')) - .join(''); - const newHistory = this.agent.context.history; for (let i = 0; i < originalHistory.length; i++) { if (newHistory[i] !== originalHistory[i]) { @@ -443,7 +436,11 @@ export class FullCompaction { readonly messages: Message[]; readonly signal: AbortSignal; readonly onRetry?: ((retryCount: number) => void) | undefined; - }): Promise<{ readonly response: GenerateResult; readonly retryCount: number }> { + }): Promise<{ + readonly response: GenerateResult; + readonly summary: string; + readonly retryCount: number; + }> { const maxAttempts = this.agent.providerManager?.config.loopControl?.maxRetriesPerStep ?? DEFAULT_MAX_RETRY_ATTEMPTS; @@ -478,7 +475,8 @@ export class FullCompaction { undefined, { signal }, ); - return { response, retryCount }; + const summary = extractCompactionSummary(response); + return { response, summary, retryCount }; } catch (error) { if (attempt >= maxAttempts || !isRetryableCompactionError(error)) { throw error; @@ -530,6 +528,20 @@ export class FullCompaction { } } +function extractCompactionSummary(response: GenerateResult): string { + const summary = + typeof response.message.content === 'string' + ? response.message.content + : response.message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); + + if (summary.trim().length === 0) { + throw new APIEmptyResponseError( + 'The compaction response did not contain a non-empty summary.', + ); + } + return summary; +} + export const COMPACTION_INSTRUCTION = (customInstruction = ''): string => renderPrompt(compactionInstructionTemplate, { customInstruction }); diff --git a/packages/agent-core/test/agent/compaction.test.ts b/packages/agent-core/test/agent/compaction.test.ts index 46a4f7cb5..5fd851134 100644 --- a/packages/agent-core/test/agent/compaction.test.ts +++ b/packages/agent-core/test/agent/compaction.test.ts @@ -435,6 +435,48 @@ describe('Agent compaction', () => { await ctx.expectResumeMatches(); }); + it('retries compaction responses with empty summaries before applying context', async () => { + vi.useFakeTimers(); + const firstEmptySummary = deferred(); + let attempts = 0; + const generate: GenerateFn = async () => { + attempts += 1; + if (attempts <= 2) { + if (attempts === 1) firstEmptySummary.resolve(); + return textResult(attempts === 1 ? '' : ' \n'); + } + return textResult('Recovered compacted summary.'); + }; + const ctx = testAgent({ generate }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + }); + appendExchange(ctx, 1, 'old user one', 'old assistant one', 20); + appendExchange(ctx, 2, 'recent user two', 'recent assistant two', 80); + const compacted = once(ctx, 'context.apply_compaction'); + + await ctx.rpc.beginCompaction({}); + await firstEmptySummary.promise; + await vi.advanceTimersByTimeAsync(10_000); + await compacted; + + expect(attempts).toBe(3); + expect(compactHistory(ctx)).toEqual([ + { role: 'assistant', text: 'Recovered compacted summary.' }, + { role: 'user', text: 'recent user two' }, + { role: 'assistant', text: 'recent assistant two' }, + ]); + expect( + ctx.allEvents.filter((event) => event.event === 'full_compaction.complete'), + ).toEqual([ + expect.objectContaining({ + args: expect.objectContaining({ summary: 'Recovered compacted summary.' }), + }), + ]); + await ctx.expectResumeMatches(); + }); + it('waits before retrying compaction generation after a retryable failure', async () => { vi.useFakeTimers(); const firstAttemptFailed = deferred();