fix: retry empty compaction summaries (#12)

Co-authored-by: liruifengv <liruifeng1024@gmail.com>
This commit is contained in:
qer 2026-05-25 14:44:22 +08:00 committed by GitHub
parent 15b018fc84
commit 89ea8959eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 70 additions and 10 deletions

View file

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

View file

@ -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 });

View file

@ -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<void>();
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<void>();