fix: continue compaction while context remains blocked (#813)

This commit is contained in:
_Kerman 2026-06-16 16:48:27 +08:00 committed by GitHub
parent 3e6196e6b2
commit 7b5b818815
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 310 additions and 215 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---
Fix repeated compaction handling when context remains over the blocking threshold.

View file

@ -2,16 +2,15 @@ import {
ErrorCodes,
KimiError,
isKimiError,
makeErrorPayload,
toKimiErrorPayload,
} from '#/errors';
import {
APIEmptyResponseError,
isRetryableGenerateError,
type GenerateResult,
type Message,
type TokenUsage,
APIContextOverflowError,
createUserMessage,
} from '@moonshot-ai/kosong';
import type { Agent } from '..';
@ -30,7 +29,6 @@ import {
resolveCompletionBudget,
} from '../../utils/completion-budget';
import compactionInstructionTemplate from './compaction-instruction.md?raw';
import { renderMessagesToText } from './render-messages';
import { renderTodoList, type TodoItem } from '../../tools/builtin/state/todo-list';
import type { CompactionBeginData, CompactionResult } from './types';
import {
@ -39,12 +37,6 @@ import {
type CompactionStrategy,
} from './strategy';
type CompactionTelemetryTrigger = CompactionBeginData['source'] | 'manual-with-prompt' | 'unknown';
export interface CompactedHistory {
text: string;
}
export const MAX_COMPACTION_RETRY_ATTEMPTS = 5;
class CompactionTruncatedError extends Error {
@ -58,12 +50,9 @@ export class FullCompaction {
protected compactionCountInTurn = 0;
protected compacting: {
abortController: AbortController;
startedAt: number;
telemetryTrigger: CompactionTelemetryTrigger;
promise: Promise<void>;
blockedByTurn: boolean;
} | null = null;
protected _compactedHistory: CompactedHistory[] = [];
protected readonly strategy: CompactionStrategy;
constructor(
@ -87,10 +76,6 @@ export class FullCompaction {
return this.compacting !== null;
}
get compactedHistory(): readonly CompactedHistory[] {
return this._compactedHistory;
}
begin(data: Readonly<CompactionBeginData>): void {
if (this.compacting) return;
if (data.source === 'manual') {
@ -114,35 +99,20 @@ export class FullCompaction {
type: 'full_compaction.begin',
...data,
});
this.startCompactionWorker(data, compactedCount);
}
private startCompactionWorker(
data: Readonly<CompactionBeginData>,
compactedCount: number,
): void {
const abortController = new AbortController();
this.agent.emitEvent({
type: 'compaction.started',
trigger: data.source,
instruction: data.instruction,
});
const active = {
const abortController = new AbortController();
this.compacting = {
abortController,
startedAt: Date.now(),
telemetryTrigger: compactionTelemetryTrigger(data.source, data.instruction),
promise: Promise.resolve(),
promise: this.compactionWorker(abortController.signal, data, compactedCount),
blockedByTurn: false,
};
this.compacting = active;
active.promise = this.compactionWorker(abortController.signal, data, compactedCount);
}
cancel(): void {
this.markCanceled();
}
private markCanceled(): void {
this.agent.replayBuilder.patchLast('compaction', {
result: 'cancelled',
});
@ -160,9 +130,6 @@ export class FullCompaction {
type: 'full_compaction.complete',
});
this.compacting = null;
this._compactedHistory.push({
text: renderMessagesToText(this.agent.context.history),
});
}
private get tokenCountWithPending(): number {
@ -197,7 +164,6 @@ export class FullCompaction {
private checkAutoCompaction(throwOnLimit: boolean = true): boolean {
if (this.compacting) return true;
if (!this.strategy.shouldCompact(this.tokenCountWithPending)) return false;
return this.beginAutoCompaction(throwOnLimit);
}
@ -236,8 +202,55 @@ export class FullCompaction {
private async compactionWorker(
signal: AbortSignal,
data: Readonly<CompactionBeginData>,
initialCompactedCount: number,
compactedCount: number,
): Promise<void> {
try {
const finalResult = {
summary: '',
compactedCount: 1,
tokensBefore: 0,
tokensAfter: 0,
};
for (let round = 1; ; round++) {
const result = await this.compactionRound(round, signal, data, compactedCount);
if (!result) return;
finalResult.summary = result.summary;
finalResult.compactedCount += result.compactedCount - 1;
finalResult.tokensBefore += result.tokensBefore - finalResult.tokensAfter;
finalResult.tokensAfter = result.tokensAfter;
if (result.tokensBefore - result.tokensAfter < 1024) break;
if (!this.strategy.shouldBlock(result.tokensAfter)) break;
compactedCount = this.strategy.computeCompactCount(this.agent.context.history, data.source);
if (compactedCount === 0) break;
}
this.markCompleted();
this.agent.emitEvent({ type: 'compaction.completed', result: finalResult });
await this.agent.injection.injectGoal();
this.triggerPostCompactHook(data, finalResult);
} catch (error) {
if (isAbortError(error)) return;
const blockedByTurn = this.compacting?.blockedByTurn === true;
this.cancel();
this.agent.log.error('compaction failed', { error });
if (blockedByTurn) {
throw error;
}
this.agent.emitEvent({
type: 'error',
...toKimiErrorPayload(error),
});
}
}
private async compactionRound(
round: number,
signal: AbortSignal,
data: Readonly<CompactionBeginData>,
initialCompactedCount: number,
) {
const startedAt = Date.now();
const originalHistory = [...this.agent.context.history];
const tokensBefore = estimateTokensForMessages(originalHistory);
@ -263,16 +276,7 @@ export class FullCompaction {
const messagesToCompact = originalHistory.slice(0, compactedCount);
const messages = [
...this.agent.context.project(messagesToCompact),
{
role: 'user',
content: [
{
type: 'text',
text: COMPACTION_INSTRUCTION(data.instruction),
},
],
toolCalls: [],
} satisfies Message,
createUserMessage(renderPrompt(compactionInstructionTemplate, { customInstruction: data.instruction ?? '' })),
];
try {
const response = await this.agent.generate(
@ -329,55 +333,30 @@ export class FullCompaction {
tokensAfter,
};
const active = this.compacting!;
this.agent.telemetry.track('compaction_finished', {
trigger_type: active.telemetryTrigger,
before_tokens: result.tokensBefore,
after_tokens: result.tokensAfter,
duration_ms: Date.now() - active.startedAt,
compacted_count: result.compactedCount,
retry_count: retryCount,
tokensBefore: result.tokensBefore,
tokensAfter: result.tokensAfter,
duration: Date.now() - startedAt,
compactedCount: result.compactedCount,
retryCount,
round,
...usage,
...data,
});
this.markCompleted();
this.agent.emitEvent({ type: 'compaction.completed', result });
this.agent.context.applyCompaction(result);
// Compaction collapses the prefix into a summary, dropping any goal
// reminder that lived there. Re-inject it onto the fresh tail so an active
// goal does not silently fall out of context. Append-only; no-op off goal mode.
await this.agent.injection.injectGoal();
this.triggerPostCompactHook(data, result);
return result;
} catch (error) {
if (!isAbortError(error)) {
const active = this.compacting;
const blockedByTurn = active?.blockedByTurn === true;
this.agent.log.error('compaction failed', {
code: isKimiError(error) ? error.code : undefined,
error,
});
this.markCanceled();
if (!blockedByTurn) {
const payload =
isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED
? toKimiErrorPayload(error)
: makeErrorPayload(ErrorCodes.COMPACTION_FAILED, String(error));
this.agent.emitEvent({
type: 'error',
...payload,
});
}
this.agent.telemetry.track('compaction_failed', {
trigger_type: compactionTelemetryTrigger(data.source, data.instruction),
before_tokens: tokensBefore,
duration_ms: Date.now() - startedAt,
retry_count: retryCount,
error_type: error instanceof Error ? error.name : 'Unknown',
});
if (blockedByTurn) {
if (isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED) throw error;
throw new KimiError(ErrorCodes.COMPACTION_FAILED, String(error), { cause: error });
}
}
if (isAbortError(error)) return;
this.agent.telemetry.track('compaction_failed', {
...data,
tokensBefore,
duration: Date.now() - startedAt,
round,
retryCount,
errorType: error instanceof Error ? error.name : 'Unknown',
});
if (isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED) throw error;
throw new KimiError(ErrorCodes.COMPACTION_FAILED, String(error), { cause: error });
}
}
@ -435,17 +414,3 @@ function extractCompactionSummary(response: GenerateResult): string {
}
return summary;
}
export const COMPACTION_INSTRUCTION = (customInstruction = ''): string =>
renderPrompt(compactionInstructionTemplate, { customInstruction });
function compactionTelemetryTrigger(
trigger: CompactionBeginData['source'] | undefined,
instruction: string | undefined,
): CompactionTelemetryTrigger {
if (trigger === undefined) return 'unknown';
if (trigger === 'manual' && instruction !== undefined && instruction.length > 0) {
return 'manual-with-prompt';
}
return trigger;
}

View file

@ -73,7 +73,7 @@ export class DefaultCompactionStrategy implements CompactionStrategy {
if (source === 'manual') {
for (let i = messages.length - 1; i > 0; i--) {
if (canSplitAfter(messages, i)) {
return i + 1;
return this.fitCompactCountToWindow(messages, i + 1);
}
}
return 0;
@ -115,7 +115,7 @@ export class DefaultCompactionStrategy implements CompactionStrategy {
}
}
return bestN ?? 0;
return this.fitCompactCountToWindow(messages, bestN ?? 0);
}
reduceCompactOnOverflow(messages: readonly Message[]): number {
@ -138,6 +138,37 @@ export class DefaultCompactionStrategy implements CompactionStrategy {
return bestN ?? messages.length;
}
private fitCompactCountToWindow(
messages: readonly Message[],
compactedCount: number,
): number {
if (this.maxSize <= 0 || compactedCount <= 0) {
return compactedCount;
}
let compactedSize = 0;
for (let i = 0; i < compactedCount; i++) {
compactedSize += estimateTokensForMessage(messages[i]!);
}
if (compactedSize <= this.maxSize) {
return compactedCount;
}
let bestN: number | undefined;
for (let n = compactedCount - 1; n > 0; n--) {
compactedSize -= estimateTokensForMessage(messages[n]!);
if (!canSplitAfter(messages, n - 1)) {
continue;
}
bestN = n;
if (compactedSize <= this.maxSize) {
return n;
}
}
return bestN ?? compactedCount;
}
get checkAfterStep(): boolean {
return this.config.triggerRatio !== this.config.blockRatio;
}

View file

@ -1,5 +1,7 @@
import type { ContentPart, Message, Tool } from '@moonshot-ai/kosong';
const messageTokenEstimateCache = new WeakMap<Message, number>();
/**
* Estimate token count from text using a character-based heuristic.
* - ASCII (~4 chars per token)
@ -40,6 +42,11 @@ export function estimateTokensForTools(tools: readonly Tool[]): number {
}
export function estimateTokensForMessage(message: Message): number {
const cached = messageTokenEstimateCache.get(message);
if (cached !== undefined) {
return cached;
}
let total = estimateTokens(message.role);
total += estimateTokensForContentParts(message.content);
if (message.toolCalls !== undefined) {
@ -48,6 +55,7 @@ export function estimateTokensForMessage(message: Message): number {
total += estimateTokens(JSON.stringify(call.arguments));
}
}
messageTokenEstimateCache.set(message, total);
return total;
}

View file

@ -186,10 +186,12 @@ describe('FullCompaction', () => {
resolve();
});
});
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' });
await ctx.rpc.beginCompaction({ instruction: 'Keep the important test facts.' });
await compacted;
await completed;
expect(ctx.newEvents()).toMatchInlineSnapshot(`
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "old user one" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
@ -199,10 +201,10 @@ describe('FullCompaction', () => {
[emit] compaction.started { "trigger": "manual", "instruction": "Keep the important test facts." }
[wire] usage.record { "model": "kimi-code", "usage": { "inputOther": 520, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 120, "maxContextTokens": 256000, "contextUsage": 0.00046875, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 520, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 520, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] full_compaction.complete { "time": "<time>" }
[emit] compaction.completed { "result": { "summary": "Compacted summary.", "compactedCount": 6, "tokensBefore": 39, "tokensAfter": 5 } }
[wire] context.apply_compaction { "summary": "Compacted summary.", "compactedCount": 6, "tokensBefore": 39, "tokensAfter": 5, "time": "<time>" }
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 5, "maxContextTokens": 256000, "contextUsage": 0.00001953125, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 520, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 520, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] full_compaction.complete { "time": "<time>" }
[emit] compaction.completed { "result": { "summary": "Compacted summary.", "compactedCount": 6, "tokensBefore": 39, "tokensAfter": 5 } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
@ -224,49 +226,21 @@ describe('FullCompaction', () => {
},
]
`);
expect(ctx.agent.fullCompaction.compactedHistory).toMatchInlineSnapshot(`
[
{
"text": "--- message 1 role=user ---
text:
old user one
--- message 2 role=assistant ---
text:
old assistant one
--- message 3 role=user ---
text:
old user two
--- message 4 role=assistant ---
text:
old assistant two
--- message 5 role=user ---
text:
recent user three
--- message 6 role=assistant ---
text:
recent assistant three",
},
]
`);
expect(records).toContainEqual({
event: 'compaction_finished',
properties: {
trigger_type: 'manual-with-prompt',
before_tokens: 39,
after_tokens: 5,
duration_ms: expect.any(Number),
compacted_count: 6,
retry_count: 0,
properties: expect.objectContaining({
source: 'manual',
instruction: 'Keep the important test facts.',
tokensBefore: 39,
tokensAfter: 5,
duration: expect.any(Number),
compactedCount: 6,
retryCount: 0,
inputOther: 520,
output: 8,
inputCacheRead: 0,
inputCacheCreation: 0,
},
}),
});
await ctx.expectResumeMatches();
});
@ -399,10 +373,12 @@ describe('FullCompaction', () => {
]);
const retryOutcome = ctx.onceAny(['context.apply_compaction', 'error']);
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
expect(await retryOutcome).toBe('context.apply_compaction');
await completed;
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token', 'fresh-token']);
expect(tokenCalls).toEqual([undefined, true, undefined]);
expect(ctx.compactHistory()).toEqual([
@ -521,17 +497,19 @@ describe('FullCompaction', () => {
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('context.apply_compaction');
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
expect(attempts).toBe(2);
expect(records).toContainEqual({
event: 'compaction_finished',
properties: expect.objectContaining({
trigger_type: 'manual',
before_tokens: 25,
retry_count: 1,
source: 'manual',
tokensBefore: 25,
retryCount: 1,
}),
});
await ctx.expectResumeMatches();
@ -557,11 +535,13 @@ describe('FullCompaction', () => {
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('context.apply_compaction');
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await firstEmptySummary.promise;
await vi.advanceTimersByTimeAsync(10_000);
await compacted;
await completed;
expect(attempts).toBe(3);
expect(ctx.compactHistory()).toEqual([
@ -678,17 +658,18 @@ describe('FullCompaction', () => {
]);
expect(records).toContainEqual({
event: 'compaction_failed',
properties: {
trigger_type: 'manual',
before_tokens: 25,
duration_ms: expect.any(Number),
retry_count: 0,
error_type: 'Error',
},
properties: expect.objectContaining({
source: 'manual',
tokensBefore: 25,
duration: expect.any(Number),
round: 1,
retryCount: 0,
errorType: 'Error',
}),
});
expect(
records.find((record) => record.event === 'compaction_failed')?.properties,
).not.toHaveProperty('after_tokens');
).not.toHaveProperty('tokensAfter');
await ctx.expectResumeMatches();
});
@ -791,13 +772,13 @@ describe('FullCompaction', () => {
expect(attempts).toBe(5);
expect(records).toContainEqual({
event: 'compaction_failed',
properties: {
trigger_type: 'manual',
before_tokens: 25,
duration_ms: expect.any(Number),
retry_count: 4,
error_type: 'APIConnectionError',
},
properties: expect.objectContaining({
source: 'manual',
tokensBefore: 25,
duration: expect.any(Number),
retryCount: 4,
errorType: 'APIConnectionError',
}),
});
await ctx.expectResumeMatches();
});
@ -817,45 +798,11 @@ describe('FullCompaction', () => {
});
ctx.mockNextResponse({ type: 'text', text: 'Rich summary.' });
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
expect(ctx.agent.fullCompaction.compactedHistory).toMatchInlineSnapshot(`
[
{
"text": "--- message 1 role=user ---
text:
old user one
--- message 2 role=assistant ---
text:
old assistant one
--- message 3 role=user ---
text:
inspect this image
image_url: ms://image-1 (id=image-1)
--- message 4 role=assistant ---
think:
checking metadata
text:
I will call Lookup.
tool calls:
- call_lookup: Lookup
arguments:
{
"query": "moon",
"limit": 2
}
--- message 5 role=tool toolCallId="call_lookup" ---
text:
lookup result
video_url: ms://video-1 (id=video-1)",
},
]
`);
await ctx.expectResumeMatches();
});
@ -868,10 +815,12 @@ describe('FullCompaction', () => {
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendPartiallyResolvedParallelToolExchange();
const compacted = ctx.once('context.apply_compaction');
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Compacted before open tools.' });
await ctx.rpc.beginCompaction({ instruction: 'Keep stable facts.' });
await compacted;
await completed;
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
@ -915,11 +864,13 @@ describe('FullCompaction', () => {
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('context.apply_compaction');
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Compacted prefix.' });
await ctx.rpc.beginCompaction({});
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'new user while compacting' }]);
await compacted;
await completed;
expect(ctx.newEvents()).toMatchInlineSnapshot(`
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "old user one" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
@ -929,10 +880,10 @@ describe('FullCompaction', () => {
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "new user while compacting" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
[wire] usage.record { "model": "kimi-code", "usage": { "inputOther": 499, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 80, "maxContextTokens": 256000, "contextUsage": 0.0003125, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 499, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 499, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] full_compaction.complete { "time": "<time>" }
[emit] compaction.completed { "result": { "summary": "Compacted prefix.", "compactedCount": 4, "tokensBefore": 25, "tokensAfter": 5 } }
[wire] context.apply_compaction { "summary": "Compacted prefix.", "compactedCount": 4, "tokensBefore": 25, "tokensAfter": 5, "time": "<time>" }
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 5, "maxContextTokens": 256000, "contextUsage": 0.00001953125, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 499, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 499, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] full_compaction.complete { "time": "<time>" }
[emit] compaction.completed { "result": { "summary": "Compacted prefix.", "compactedCount": 4, "tokensBefore": 25, "tokensAfter": 5 } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
@ -959,6 +910,100 @@ describe('FullCompaction', () => {
await ctx.expectResumeMatches();
});
it('continues a manual compaction run when the first pass still exceeds the trigger', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 4_000,
},
});
ctx.appendExchange(
1,
`old user one ${'u'.repeat(14_000)}`,
`old assistant one ${'a'.repeat(14_000)}`,
6_000,
);
const firstSummary = `large manual summary ${'x'.repeat(14_000)}`;
let appliedCount = 0;
const secondCompacted = new Promise<void>((resolve) => {
const handler = () => {
appliedCount += 1;
if (appliedCount === 2) {
ctx.emitter.off('context.apply_compaction', handler);
resolve();
}
};
ctx.emitter.on('context.apply_compaction', handler);
});
ctx.mockNextResponse({ type: 'text', text: firstSummary });
ctx.mockNextResponse({ type: 'text', text: 'Second manual summary.' });
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
ctx.appendExchange(2, 'new user while compacting', 'new assistant while compacting', 6_000);
await secondCompacted;
await completed;
const events = ctx.newEvents();
expect(countEvents(events, 'context.apply_compaction')).toBe(2);
expect(countEvents(events, 'compaction.started')).toBe(1);
expect(countEvents(events, 'compaction.completed')).toBe(1);
expect(ctx.llmCalls).toHaveLength(2);
const [firstCompactionCall, secondCompactionCall] = ctx.llmCalls;
expect(firstCompactionCall?.history.map(messageText)).not.toContain('new user while compacting');
expect(secondCompactionCall?.history.map(messageText)).toContain(firstSummary);
expect(secondCompactionCall?.history.map(messageText)).toContain('new user while compacting');
expect(secondCompactionCall?.history.map(messageText)).toContain('new assistant while compacting');
expect(ctx.compactHistory()).toEqual([
{
role: 'assistant',
text: 'Second manual summary.',
},
]);
await ctx.expectResumeMatches();
});
it('auto-compacts very large context in window-sized rounds', async () => {
const maxContextTokens = 4_000;
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: maxContextTokens,
},
});
for (let i = 1; i <= 22; i++) {
ctx.appendAssistantTextWithUsage(
i,
`history chunk ${String(i)} ${'x'.repeat(7_200)}`,
i * 1_850,
);
}
const initialTokens = estimateTokensForMessages(ctx.agent.context.history);
const completed = ctx.once('compaction.completed');
for (let i = 1; i <= 30; i++) {
ctx.mockNextResponse({ type: 'text', text: `Auto summary ${String(i)}.` });
}
ctx.agent.fullCompaction.begin({ source: 'auto', instruction: undefined });
await completed;
const events = ctx.newEvents();
const compactedPrefixSizes = ctx.llmCalls.map((call) =>
estimateTokensForMessages(call.history.slice(0, -1)),
);
expect(initialTokens).toBeGreaterThan(maxContextTokens * 9);
expect(countEvents(events, 'context.apply_compaction')).toBeGreaterThan(1);
expect(countEvents(events, 'compaction.completed')).toBe(1);
expect(compactedPrefixSizes.length).toBeGreaterThan(1);
expect(compactedPrefixSizes.every((size) => size <= maxContextTokens)).toBe(true);
expect(ctx.agent.context.tokenCount).toBeLessThan(maxContextTokens * 0.85);
await ctx.expectResumeMatches();
});
it('cancels when the compacted prefix changes before completion', async () => {
const ctx = testAgent();
ctx.configure({
@ -1027,10 +1072,10 @@ describe('FullCompaction', () => {
[emit] compaction.blocked { "turnId": 0 }
[wire] usage.record { "model": "kimi-code", "usage": { "inputOther": 498, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 950000, "maxContextTokens": 256000, "contextUsage": 3.7109375, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 498, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 498, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] full_compaction.complete { "time": "<time>" }
[emit] compaction.completed { "result": { "summary": "Auto compacted summary.", "compactedCount": 4, "tokensBefore": 46, "tokensAfter": 28 } }
[wire] context.apply_compaction { "summary": "Auto compacted summary.", "compactedCount": 4, "tokensBefore": 46, "tokensAfter": 28, "time": "<time>" }
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 28, "maxContextTokens": 256000, "contextUsage": 0.000109375, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 498, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 498, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] full_compaction.complete { "time": "<time>" }
[emit] compaction.completed { "result": { "summary": "Auto compacted summary.", "compactedCount": 4, "tokensBefore": 46, "tokensAfter": 28 } }
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-1>", "turnId": "0", "step": 1 }, "time": "<time>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] assistant.delta { "turnId": 0, "delta": "I can answer after compaction." }
@ -1062,11 +1107,11 @@ describe('FullCompaction', () => {
expect(records).toContainEqual({
event: 'compaction_finished',
properties: expect.objectContaining({
trigger_type: 'auto',
before_tokens: 46,
after_tokens: 28,
compacted_count: 4,
retry_count: 0,
source: 'auto',
tokensBefore: 46,
tokensAfter: 28,
compactedCount: 4,
retryCount: 0,
}),
});
await ctx.expectResumeMatches();
@ -1241,10 +1286,12 @@ describe('FullCompaction', () => {
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('context.apply_compaction');
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Compacted after no-op cancel.' });
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
expect(ctx.llmCalls).toHaveLength(1);
expect(ctx.compactHistory()).toEqual([
@ -1694,10 +1741,10 @@ describe('FullCompaction', () => {
[emit] compaction.blocked { "turnId": 0 }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 482, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "mock-model": { "inputOther": 482, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 482, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] full_compaction.complete { "time": "<time>" }
[emit] compaction.completed { "result": { "summary": "First compacted summary.", "compactedCount": 1, "tokensBefore": 8, "tokensAfter": 6 } }
[wire] context.apply_compaction { "summary": "First compacted summary.", "compactedCount": 1, "tokensBefore": 8, "tokensAfter": 6, "time": "<time>" }
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 6, "maxContextTokens": 1000000, "contextUsage": 0.000006, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "mock-model": { "inputOther": 482, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 482, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] full_compaction.complete { "time": "<time>" }
[emit] compaction.completed { "result": { "summary": "First compacted summary.", "compactedCount": 1, "tokensBefore": 8, "tokensAfter": 6 } }
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-1>", "turnId": "0", "step": 1 }, "time": "<time>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] assistant.delta { "turnId": 0, "delta": "I need a tool." }
@ -1751,10 +1798,12 @@ describe('FullCompaction', () => {
resolve();
});
});
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' });
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
const history = ctx.compactHistory();
expect(history).toHaveLength(1);
@ -1801,6 +1850,13 @@ function eventIndex(events: ReturnType<TestAgentContext['newEvents']>, type: str
});
}
function countEvents(events: ReturnType<TestAgentContext['newEvents']>, type: string): number {
return events.filter((event) => {
if (typeof event !== 'object' || event === null) return false;
return (event as { readonly event?: unknown }).event === type;
}).length;
}
function oauthTestAgentOptions(
getAccessToken: (options?: { readonly force?: boolean }) => Promise<string>,
): Pick<TestAgentOptions, 'initialConfig' | 'providerManagerOverrides'> {

View file

@ -5,6 +5,7 @@ import {
import { describe, expect, it } from 'vitest';
import { DefaultCompactionStrategy } from '../../../src/agent/compaction';
import { estimateTokensForMessages } from '../../../src/utils/tokens';
describe('DefaultCompactionStrategy', () => {
it('keeps an oversized trailing user message as recent', () => {
@ -96,6 +97,36 @@ describe('DefaultCompactionStrategy', () => {
expect(strategy.computeCompactCount(messages, 'auto')).toBe(2);
});
it('shrinks auto compaction input to fit the model window', () => {
const maxSize = 1_000;
const strategy = testCompactionStrategy(maxSize);
const messages = Array.from({ length: 30 }, (_, i) =>
textMessage('assistant', `message ${i} ${'x'.repeat(400)}`),
);
const count = strategy.computeCompactCount(messages, 'auto');
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThan(messages.length);
expect(estimateTokensForMessages(messages.slice(0, count))).toBeLessThanOrEqual(maxSize);
expect(estimateTokensForMessages(messages.slice(0, count + 1))).toBeGreaterThan(maxSize);
});
it('shrinks manual compaction input to fit the model window', () => {
const maxSize = 1_000;
const strategy = testCompactionStrategy(maxSize);
const messages = Array.from({ length: 30 }, (_, i) =>
textMessage('assistant', `message ${i} ${'x'.repeat(400)}`),
);
const count = strategy.computeCompactCount(messages, 'manual');
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThan(messages.length);
expect(estimateTokensForMessages(messages.slice(0, count))).toBeLessThanOrEqual(maxSize);
expect(estimateTokensForMessages(messages.slice(0, count + 1))).toBeGreaterThan(maxSize);
});
it('reserves response context by default before the ratio threshold is reached', () => {
const strategy = new DefaultCompactionStrategy(() => 256_000);

View file

@ -82,7 +82,6 @@ interface ResumeStateSnapshot {
readonly systemPrompt: string;
};
readonly context: ReturnType<Agent['context']['data']>;
readonly fullCompaction: Agent['fullCompaction']['compactedHistory'];
readonly permission: ReturnType<Agent['permission']['data']>;
readonly tools: ReturnType<Agent['tools']['data']>;
readonly toolStore: ReturnType<Agent['tools']['storeData']>;
@ -1002,7 +1001,6 @@ function resumeStateSnapshot(agent: Agent): ResumeStateSnapshot {
background: agent.background.list(false),
config: configStateSnapshot(agent),
context: resumeContextSnapshot(agent),
fullCompaction: agent.fullCompaction.compactedHistory,
permission: agent.permission.data(),
tools: agent.tools.data(),
toolStore: agent.tools.storeData(),