mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix: continue compaction while context remains blocked (#813)
This commit is contained in:
parent
3e6196e6b2
commit
7b5b818815
7 changed files with 310 additions and 215 deletions
6
.changeset/fresh-compaction-rounds.md
Normal file
6
.changeset/fresh-compaction-rounds.md
Normal 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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue