Skip to content

Commit 7b5b818

Browse files
authored
fix: continue compaction while context remains blocked (#813)
1 parent 3e6196e commit 7b5b818

7 files changed

Lines changed: 310 additions & 215 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@moonshot-ai/agent-core": patch
3+
"@moonshot-ai/kimi-code": patch
4+
---
5+
6+
Fix repeated compaction handling when context remains over the blocking threshold.

packages/agent-core/src/agent/compaction/full.ts

Lines changed: 72 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@ import {
22
ErrorCodes,
33
KimiError,
44
isKimiError,
5-
makeErrorPayload,
65
toKimiErrorPayload,
76
} from '#/errors';
87
import {
98
APIEmptyResponseError,
109
isRetryableGenerateError,
1110
type GenerateResult,
12-
type Message,
1311
type TokenUsage,
1412
APIContextOverflowError,
13+
createUserMessage,
1514
} from '@moonshot-ai/kosong';
1615

1716
import type { Agent } from '..';
@@ -30,7 +29,6 @@ import {
3029
resolveCompletionBudget,
3130
} from '../../utils/completion-budget';
3231
import compactionInstructionTemplate from './compaction-instruction.md?raw';
33-
import { renderMessagesToText } from './render-messages';
3432
import { renderTodoList, type TodoItem } from '../../tools/builtin/state/todo-list';
3533
import type { CompactionBeginData, CompactionResult } from './types';
3634
import {
@@ -39,12 +37,6 @@ import {
3937
type CompactionStrategy,
4038
} from './strategy';
4139

42-
type CompactionTelemetryTrigger = CompactionBeginData['source'] | 'manual-with-prompt' | 'unknown';
43-
44-
export interface CompactedHistory {
45-
text: string;
46-
}
47-
4840
export const MAX_COMPACTION_RETRY_ATTEMPTS = 5;
4941

5042
class CompactionTruncatedError extends Error {
@@ -58,12 +50,9 @@ export class FullCompaction {
5850
protected compactionCountInTurn = 0;
5951
protected compacting: {
6052
abortController: AbortController;
61-
startedAt: number;
62-
telemetryTrigger: CompactionTelemetryTrigger;
6353
promise: Promise<void>;
6454
blockedByTurn: boolean;
6555
} | null = null;
66-
protected _compactedHistory: CompactedHistory[] = [];
6756
protected readonly strategy: CompactionStrategy;
6857

6958
constructor(
@@ -87,10 +76,6 @@ export class FullCompaction {
8776
return this.compacting !== null;
8877
}
8978

90-
get compactedHistory(): readonly CompactedHistory[] {
91-
return this._compactedHistory;
92-
}
93-
9479
begin(data: Readonly<CompactionBeginData>): void {
9580
if (this.compacting) return;
9681
if (data.source === 'manual') {
@@ -114,35 +99,20 @@ export class FullCompaction {
11499
type: 'full_compaction.begin',
115100
...data,
116101
});
117-
this.startCompactionWorker(data, compactedCount);
118-
}
119-
120-
private startCompactionWorker(
121-
data: Readonly<CompactionBeginData>,
122-
compactedCount: number,
123-
): void {
124-
const abortController = new AbortController();
125102
this.agent.emitEvent({
126103
type: 'compaction.started',
127104
trigger: data.source,
128105
instruction: data.instruction,
129106
});
130-
const active = {
107+
const abortController = new AbortController();
108+
this.compacting = {
131109
abortController,
132-
startedAt: Date.now(),
133-
telemetryTrigger: compactionTelemetryTrigger(data.source, data.instruction),
134-
promise: Promise.resolve(),
110+
promise: this.compactionWorker(abortController.signal, data, compactedCount),
135111
blockedByTurn: false,
136112
};
137-
this.compacting = active;
138-
active.promise = this.compactionWorker(abortController.signal, data, compactedCount);
139113
}
140114

141115
cancel(): void {
142-
this.markCanceled();
143-
}
144-
145-
private markCanceled(): void {
146116
this.agent.replayBuilder.patchLast('compaction', {
147117
result: 'cancelled',
148118
});
@@ -160,9 +130,6 @@ export class FullCompaction {
160130
type: 'full_compaction.complete',
161131
});
162132
this.compacting = null;
163-
this._compactedHistory.push({
164-
text: renderMessagesToText(this.agent.context.history),
165-
});
166133
}
167134

168135
private get tokenCountWithPending(): number {
@@ -197,7 +164,6 @@ export class FullCompaction {
197164
private checkAutoCompaction(throwOnLimit: boolean = true): boolean {
198165
if (this.compacting) return true;
199166
if (!this.strategy.shouldCompact(this.tokenCountWithPending)) return false;
200-
201167
return this.beginAutoCompaction(throwOnLimit);
202168
}
203169

@@ -236,8 +202,55 @@ export class FullCompaction {
236202
private async compactionWorker(
237203
signal: AbortSignal,
238204
data: Readonly<CompactionBeginData>,
239-
initialCompactedCount: number,
205+
compactedCount: number,
240206
): Promise<void> {
207+
try {
208+
const finalResult = {
209+
summary: '',
210+
compactedCount: 1,
211+
tokensBefore: 0,
212+
tokensAfter: 0,
213+
};
214+
215+
for (let round = 1; ; round++) {
216+
const result = await this.compactionRound(round, signal, data, compactedCount);
217+
if (!result) return;
218+
219+
finalResult.summary = result.summary;
220+
finalResult.compactedCount += result.compactedCount - 1;
221+
finalResult.tokensBefore += result.tokensBefore - finalResult.tokensAfter;
222+
finalResult.tokensAfter = result.tokensAfter;
223+
224+
if (result.tokensBefore - result.tokensAfter < 1024) break;
225+
if (!this.strategy.shouldBlock(result.tokensAfter)) break;
226+
compactedCount = this.strategy.computeCompactCount(this.agent.context.history, data.source);
227+
if (compactedCount === 0) break;
228+
}
229+
this.markCompleted();
230+
this.agent.emitEvent({ type: 'compaction.completed', result: finalResult });
231+
await this.agent.injection.injectGoal();
232+
this.triggerPostCompactHook(data, finalResult);
233+
} catch (error) {
234+
if (isAbortError(error)) return;
235+
const blockedByTurn = this.compacting?.blockedByTurn === true;
236+
this.cancel();
237+
this.agent.log.error('compaction failed', { error });
238+
if (blockedByTurn) {
239+
throw error;
240+
}
241+
this.agent.emitEvent({
242+
type: 'error',
243+
...toKimiErrorPayload(error),
244+
});
245+
}
246+
}
247+
248+
private async compactionRound(
249+
round: number,
250+
signal: AbortSignal,
251+
data: Readonly<CompactionBeginData>,
252+
initialCompactedCount: number,
253+
) {
241254
const startedAt = Date.now();
242255
const originalHistory = [...this.agent.context.history];
243256
const tokensBefore = estimateTokensForMessages(originalHistory);
@@ -263,16 +276,7 @@ export class FullCompaction {
263276
const messagesToCompact = originalHistory.slice(0, compactedCount);
264277
const messages = [
265278
...this.agent.context.project(messagesToCompact),
266-
{
267-
role: 'user',
268-
content: [
269-
{
270-
type: 'text',
271-
text: COMPACTION_INSTRUCTION(data.instruction),
272-
},
273-
],
274-
toolCalls: [],
275-
} satisfies Message,
279+
createUserMessage(renderPrompt(compactionInstructionTemplate, { customInstruction: data.instruction ?? '' })),
276280
];
277281
try {
278282
const response = await this.agent.generate(
@@ -329,55 +333,30 @@ export class FullCompaction {
329333
tokensAfter,
330334
};
331335

332-
const active = this.compacting!;
333336
this.agent.telemetry.track('compaction_finished', {
334-
trigger_type: active.telemetryTrigger,
335-
before_tokens: result.tokensBefore,
336-
after_tokens: result.tokensAfter,
337-
duration_ms: Date.now() - active.startedAt,
338-
compacted_count: result.compactedCount,
339-
retry_count: retryCount,
337+
tokensBefore: result.tokensBefore,
338+
tokensAfter: result.tokensAfter,
339+
duration: Date.now() - startedAt,
340+
compactedCount: result.compactedCount,
341+
retryCount,
342+
round,
340343
...usage,
344+
...data,
341345
});
342-
this.markCompleted();
343-
this.agent.emitEvent({ type: 'compaction.completed', result });
344346
this.agent.context.applyCompaction(result);
345-
// Compaction collapses the prefix into a summary, dropping any goal
346-
// reminder that lived there. Re-inject it onto the fresh tail so an active
347-
// goal does not silently fall out of context. Append-only; no-op off goal mode.
348-
await this.agent.injection.injectGoal();
349-
this.triggerPostCompactHook(data, result);
347+
return result;
350348
} catch (error) {
351-
if (!isAbortError(error)) {
352-
const active = this.compacting;
353-
const blockedByTurn = active?.blockedByTurn === true;
354-
this.agent.log.error('compaction failed', {
355-
code: isKimiError(error) ? error.code : undefined,
356-
error,
357-
});
358-
this.markCanceled();
359-
if (!blockedByTurn) {
360-
const payload =
361-
isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED
362-
? toKimiErrorPayload(error)
363-
: makeErrorPayload(ErrorCodes.COMPACTION_FAILED, String(error));
364-
this.agent.emitEvent({
365-
type: 'error',
366-
...payload,
367-
});
368-
}
369-
this.agent.telemetry.track('compaction_failed', {
370-
trigger_type: compactionTelemetryTrigger(data.source, data.instruction),
371-
before_tokens: tokensBefore,
372-
duration_ms: Date.now() - startedAt,
373-
retry_count: retryCount,
374-
error_type: error instanceof Error ? error.name : 'Unknown',
375-
});
376-
if (blockedByTurn) {
377-
if (isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED) throw error;
378-
throw new KimiError(ErrorCodes.COMPACTION_FAILED, String(error), { cause: error });
379-
}
380-
}
349+
if (isAbortError(error)) return;
350+
this.agent.telemetry.track('compaction_failed', {
351+
...data,
352+
tokensBefore,
353+
duration: Date.now() - startedAt,
354+
round,
355+
retryCount,
356+
errorType: error instanceof Error ? error.name : 'Unknown',
357+
});
358+
if (isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED) throw error;
359+
throw new KimiError(ErrorCodes.COMPACTION_FAILED, String(error), { cause: error });
381360
}
382361
}
383362

@@ -435,17 +414,3 @@ function extractCompactionSummary(response: GenerateResult): string {
435414
}
436415
return summary;
437416
}
438-
439-
export const COMPACTION_INSTRUCTION = (customInstruction = ''): string =>
440-
renderPrompt(compactionInstructionTemplate, { customInstruction });
441-
442-
function compactionTelemetryTrigger(
443-
trigger: CompactionBeginData['source'] | undefined,
444-
instruction: string | undefined,
445-
): CompactionTelemetryTrigger {
446-
if (trigger === undefined) return 'unknown';
447-
if (trigger === 'manual' && instruction !== undefined && instruction.length > 0) {
448-
return 'manual-with-prompt';
449-
}
450-
return trigger;
451-
}

packages/agent-core/src/agent/compaction/strategy.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export class DefaultCompactionStrategy implements CompactionStrategy {
7373
if (source === 'manual') {
7474
for (let i = messages.length - 1; i > 0; i--) {
7575
if (canSplitAfter(messages, i)) {
76-
return i + 1;
76+
return this.fitCompactCountToWindow(messages, i + 1);
7777
}
7878
}
7979
return 0;
@@ -115,7 +115,7 @@ export class DefaultCompactionStrategy implements CompactionStrategy {
115115
}
116116
}
117117

118-
return bestN ?? 0;
118+
return this.fitCompactCountToWindow(messages, bestN ?? 0);
119119
}
120120

121121
reduceCompactOnOverflow(messages: readonly Message[]): number {
@@ -138,6 +138,37 @@ export class DefaultCompactionStrategy implements CompactionStrategy {
138138
return bestN ?? messages.length;
139139
}
140140

141+
private fitCompactCountToWindow(
142+
messages: readonly Message[],
143+
compactedCount: number,
144+
): number {
145+
if (this.maxSize <= 0 || compactedCount <= 0) {
146+
return compactedCount;
147+
}
148+
149+
let compactedSize = 0;
150+
for (let i = 0; i < compactedCount; i++) {
151+
compactedSize += estimateTokensForMessage(messages[i]!);
152+
}
153+
if (compactedSize <= this.maxSize) {
154+
return compactedCount;
155+
}
156+
157+
let bestN: number | undefined;
158+
for (let n = compactedCount - 1; n > 0; n--) {
159+
compactedSize -= estimateTokensForMessage(messages[n]!);
160+
if (!canSplitAfter(messages, n - 1)) {
161+
continue;
162+
}
163+
bestN = n;
164+
if (compactedSize <= this.maxSize) {
165+
return n;
166+
}
167+
}
168+
169+
return bestN ?? compactedCount;
170+
}
171+
141172
get checkAfterStep(): boolean {
142173
return this.config.triggerRatio !== this.config.blockRatio;
143174
}

packages/agent-core/src/utils/tokens.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { ContentPart, Message, Tool } from '@moonshot-ai/kosong';
22

3+
const messageTokenEstimateCache = new WeakMap<Message, number>();
4+
35
/**
46
* Estimate token count from text using a character-based heuristic.
57
* - ASCII (~4 chars per token)
@@ -40,6 +42,11 @@ export function estimateTokensForTools(tools: readonly Tool[]): number {
4042
}
4143

4244
export function estimateTokensForMessage(message: Message): number {
45+
const cached = messageTokenEstimateCache.get(message);
46+
if (cached !== undefined) {
47+
return cached;
48+
}
49+
4350
let total = estimateTokens(message.role);
4451
total += estimateTokensForContentParts(message.content);
4552
if (message.toolCalls !== undefined) {
@@ -48,6 +55,7 @@ export function estimateTokensForMessage(message: Message): number {
4855
total += estimateTokens(JSON.stringify(call.arguments));
4956
}
5057
}
58+
messageTokenEstimateCache.set(message, total);
5159
return total;
5260
}
5361

0 commit comments

Comments
 (0)