Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/retry-empty-compaction-summary.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 22 additions & 10 deletions packages/agent-core/src/agent/compaction/full.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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]) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });

Expand Down
42 changes: 42 additions & 0 deletions packages/agent-core/test/agent/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>();
Expand Down
Loading