Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/auto-compaction-circuit-breaker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix auto-compaction looping forever when the model's context window is small relative to the CLI's fixed request overhead (system prompt and tool schemas) — e.g. a self-hosted `openai_responses` model with a 32k `max_context_size`, where even an empty conversation trips the 0.85 trigger ratio. Auto-compaction now detects when repeated compactions fail to reduce the steady-state token count and disables itself for the rest of the session with a warning (suggesting a larger `max_context_size`, a lower `loop_control.reserved_context_size`, or a higher `loop_control.compaction_trigger_ratio`) instead of compacting on every step.
8 changes: 7 additions & 1 deletion packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
// references become '(circular)', and class instances collapse to a '(ClassName)'
// marker — the wire shape of an entry is the JSON projection of the type here.
//
// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 67 keys)
// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 70 keys)
// App
// Workspace
// workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts
Expand Down Expand Up @@ -62,10 +62,13 @@
// contextSize.lastEmittedTokens src/agent/contextSize/contextSizeService.ts
// externalHooks.stopHookContinuationUsed src/agent/externalHooks/externalHooksService.ts
// fullCompaction.activeTurnId src/agent/fullCompaction/fullCompactionService.ts
// fullCompaction.autoCompactionDisabled src/agent/fullCompaction/fullCompactionService.ts
// fullCompaction.compactionCountInTurn src/agent/fullCompaction/fullCompactionService.ts
// fullCompaction.consecutiveOverflowCompactions src/agent/fullCompaction/fullCompactionService.ts
// fullCompaction.ineffectiveAutoCompactions src/agent/fullCompaction/fullCompactionService.ts
// fullCompaction.lastCompactedTokenCount src/agent/fullCompaction/fullCompactionService.ts
// fullCompaction.observedMaxContextTokensByModel src/agent/fullCompaction/fullCompactionService.ts
// fullCompaction.pendingAutoCompactionCount src/agent/fullCompaction/fullCompactionService.ts
// goal.budgetGraceTurns src/agent/goal/goalService.ts
// goal.countedGoalTurns src/agent/goal/goalService.ts
// goal.exhaustedTurnBudgetGoals src/agent/goal/goalService.ts
Expand Down Expand Up @@ -979,10 +982,13 @@ export interface AgentStateSnapshot {
'externalHooks.stopHookContinuationUsed': boolean;
// src/agent/fullCompaction/fullCompactionService.ts
'fullCompaction.activeTurnId': number | undefined;
'fullCompaction.autoCompactionDisabled': boolean;
'fullCompaction.compactionCountInTurn': number;
'fullCompaction.consecutiveOverflowCompactions': number;
'fullCompaction.ineffectiveAutoCompactions': number;
'fullCompaction.lastCompactedTokenCount': number | null;
'fullCompaction.observedMaxContextTokensByModel': Map<string, number>;
'fullCompaction.pendingAutoCompactionCount': number | null;
// src/agent/goal/goalService.ts
'goal.budgetGraceTurns': Set<number>;
'goal.countedGoalTurns': Set<number>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
* the compaction LLM round (with overflow / truncation shrink retries),
* applies the summary back into context memory, and recovers the loop from
* context-overflow failures by blocking the turn on the in-flight job. The
* service disables automatic compaction for the session when repeated runs
* cannot reduce fixed request overhead below the configured threshold. The
* mutable plain-data state (`compactionCountInTurn`,
* `observedMaxContextTokensByModel`, `lastCompactedTokenCount`,
* `consecutiveOverflowCompactions`, `activeTurnId`) is registered into
* `consecutiveOverflowCompactions`, `activeTurnId`,
* `pendingAutoCompactionCount`, `ineffectiveAutoCompactions`,
* `autoCompactionDisabled`) is registered into
* `agentState` (`IAgentStateService`) and read/written through it;
* `_compacting` (the in-flight job — AbortController / Promise / trace), the
* `hooks.onWillCompact` slot, the `_onDidFinishCompaction` Emitter, the
Expand Down Expand Up @@ -89,6 +93,8 @@ const OVERFLOW_CONTEXT_SAFETY_RATIO = 0.85;
const OVERFLOW_STATUS_RECOVERY_RATIO = 0.5;
const MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS = 3;
const COMPACTION_OVERFLOW_SHRINK_RATIOS = [0.7, 0.5, 0.35] as const;
const INEFFECTIVE_AUTO_COMPACTION_RATIO = 0.8;
const MAX_INEFFECTIVE_AUTO_COMPACTIONS = 2;
const EMPTY_TOOL_PARAMETERS: Record<string, unknown> = {
type: 'object',
properties: {},
Expand Down Expand Up @@ -138,6 +144,18 @@ export const fullCompactionActiveTurnIdKey = defineState<number | undefined>(
'fullCompaction.activeTurnId',
() => undefined as number | undefined,
);
export const fullCompactionPendingAutoCompactionCountKey = defineState<number | null>(
'fullCompaction.pendingAutoCompactionCount',
() => null,
);
export const fullCompactionIneffectiveAutoCompactionsKey = defineState<number>(
'fullCompaction.ineffectiveAutoCompactions',
() => 0,
);
export const fullCompactionAutoCompactionDisabledKey = defineState<boolean>(
'fullCompaction.autoCompactionDisabled',
() => false,
);

export class AgentFullCompactionService extends Disposable implements IAgentFullCompactionService {
declare readonly _serviceBrand: undefined;
Expand Down Expand Up @@ -173,6 +191,9 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
this.states.register(fullCompactionLastCompactedTokenCountKey);
this.states.register(fullCompactionConsecutiveOverflowCompactionsKey);
this.states.register(fullCompactionActiveTurnIdKey);
this.states.register(fullCompactionPendingAutoCompactionCountKey);
this.states.register(fullCompactionIneffectiveAutoCompactionsKey);
this.states.register(fullCompactionAutoCompactionDisabledKey);
Comment thread
xy200303 marked this conversation as resolved.
this.strategy = new RuntimeCompactionStrategy(() => this.resolveModelContextWithEffectiveMax());
this._register(
this.wire.hooks.onDidRestore.register('full-compaction', async (_ctx, next) => {
Expand Down Expand Up @@ -245,6 +266,30 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
this.states.set(fullCompactionActiveTurnIdKey, value);
}

private get pendingAutoCompactionCount(): number | null {
return this.states.get(fullCompactionPendingAutoCompactionCountKey);
}

private set pendingAutoCompactionCount(value: number | null) {
this.states.set(fullCompactionPendingAutoCompactionCountKey, value);
}

private get ineffectiveAutoCompactions(): number {
return this.states.get(fullCompactionIneffectiveAutoCompactionsKey);
}

private set ineffectiveAutoCompactions(value: number) {
this.states.set(fullCompactionIneffectiveAutoCompactionsKey, value);
}

private get autoCompactionDisabled(): boolean {
return this.states.get(fullCompactionAutoCompactionDisabledKey);
}

private set autoCompactionDisabled(value: boolean) {
this.states.set(fullCompactionAutoCompactionDisabledKey, value);
}

get compacting(): FullCompactionTask | null {
return this._compacting;
}
Expand Down Expand Up @@ -487,14 +532,44 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull

private checkAutoCompaction(throwOnLimit = true): boolean {
if (this._compacting) return true;
const usedSize = this.tokenCountWithPending();
this.evaluateAutoCompactionEffectiveness(usedSize);
if (
this.lastCompactedTokenCount !== null &&
this.tokenCountWithPending() <= this.lastCompactedTokenCount
usedSize <= this.lastCompactedTokenCount
) {
return false;
}
if (!this.strategy.shouldCompact(this.tokenCountWithPending())) return false;
return this.beginAutoCompaction(throwOnLimit);
if (!this.strategy.shouldCompact(usedSize)) return false;
if (this.autoCompactionDisabled) return false;
const began = this.beginAutoCompaction(throwOnLimit);
if (began) this.pendingAutoCompactionCount = usedSize;
return began;
}

private evaluateAutoCompactionEffectiveness(usedSize: number): void {
const baseline = this.pendingAutoCompactionCount;
if (baseline === null) return;
this.pendingAutoCompactionCount = null;
if (usedSize < baseline * INEFFECTIVE_AUTO_COMPACTION_RATIO) {
this.ineffectiveAutoCompactions = 0;
return;
}
this.ineffectiveAutoCompactions += 1;
if (this.ineffectiveAutoCompactions < MAX_INEFFECTIVE_AUTO_COMPACTIONS) return;
this.autoCompactionDisabled = true;
const maxContextTokens = this.getEffectiveMaxContextTokens();
const message =
`Auto-compaction disabled: repeated compactions failed to reduce the context size ` +
`(~${String(usedSize)} tokens remain against a ${String(maxContextTokens)}-token window). ` +
`The CLI's baseline request overhead likely exceeds the compaction threshold for this model; ` +
`increase the model's max_context_size, lower loop_control.reserved_context_size, or raise loop_control.compaction_trigger_ratio.`;
this.log.warn(message, { usedSize, maxContextTokens });
try {
this.eventBus.publish({ type: 'warning', code: 'auto-compaction-ineffective', message });
} catch {
// Diagnostics must never break the compaction check.
}
}

private beginAutoCompaction(throwOnLimit = true): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import {
DefaultCompactionStrategy,
} from '#/agent/fullCompaction/strategy';
import { fullCompactionAutoCompactionDisabledKey } from '#/agent/fullCompaction/fullCompactionService';
import { IAgentStateService } from '#/agent/state/agentState';
import { COMPACTION_SUMMARY_PREFIX } from '#/agent/contextMemory/compactionHandoff';
import { makeHookRunner } from '../externalHooks/runner-stub';
import type { IExternalHooksRunnerService } from '#/app/externalHooksRunner/externalHooksRunner';
Expand All @@ -51,6 +53,7 @@ import {
} from '#/index';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { IAgentGoalService } from '#/agent/goal/goal';
import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext';
import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
Expand Down Expand Up @@ -2072,6 +2075,129 @@ describe('FullCompaction', () => {
await ctx.expectResumeMatches();
});

it('disables auto compaction after two ineffective compactions instead of looping', async () => {
// Reproduces the small-window loop from upstream issue #2325: the trigger
// threshold (0.85 * 2_000 = 1_700) sits below the steady-state usage the
// server keeps reporting (~1_800), because the fixed request overhead
// alone exceeds the threshold. Every compaction shrinks only the
// conversation, the next measured usage jumps back over the threshold,
// and without the circuit breaker auto-compaction would loop forever.
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 2_000,
},
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 1_800);

// Turn 1: compaction #1 runs before the step.
ctx.mockNextResponse({ type: 'text', text: 'Ineffective compacted summary one.' });
ctx.mockNextResponse({ type: 'text', text: 'Answer one.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'loop prompt one' }] });
await ctx.untilTurnEnd();

// The server keeps reporting the same overhead-dominated usage, so the
// pre-step check marks compaction #1 ineffective and starts #2.
bumpUsage(ctx, 1_800);
ctx.mockNextResponse({ type: 'text', text: 'Ineffective compacted summary two.' });
ctx.mockNextResponse({ type: 'text', text: 'Answer two.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'loop prompt two' }] });
await ctx.untilTurnEnd();

// Turn 3: the steady-state count is still >= 80% of the count that
// triggered #2 — the second ineffective compaction in a row — so
// auto-compaction is disabled instead of starting a third round.
bumpUsage(ctx, 1_800);
ctx.mockNextResponse({ type: 'text', text: 'Answer three.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'loop prompt three' }] });
await ctx.untilTurnEnd();

const compactionCalls = ctx.llmCalls.filter((call) =>
messageText(call.history.at(-1)).includes('first-person handoff note'),
);
expect(compactionCalls).toHaveLength(2);
expect(ctx.llmCalls).toHaveLength(5);
const warnings = ctx.allEvents.filter((entry) => entry.event === 'warning');
expect(warnings).toHaveLength(1);
expect(warnings[0]?.args).toEqual(
expect.objectContaining({
code: 'auto-compaction-ineffective',
message: expect.stringContaining('Auto-compaction disabled'),
}),
);
expect(ctx.get(IAgentStateService).get(fullCompactionAutoCompactionDisabledKey)).toBe(true);
await ctx.expectResumeMatches();
});

it('resets the ineffective streak after an effective compaction', async () => {
// Regression guard against false positives: a compaction that genuinely
// drops the steady-state count below 80% of its trigger count resets the
// streak, so only two CONSECUTIVE ineffective compactions open the
// circuit. An accumulated (unreset) streak would open it one round
// earlier and produce only 3 compaction calls below.
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 2_000,
},
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 1_800);

// Turn 1: compaction #1.
ctx.mockNextResponse({ type: 'text', text: 'Compacted summary one.' });
ctx.mockNextResponse({ type: 'text', text: 'Answer one.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'prompt one' }] });
await ctx.untilTurnEnd();

// Turn 2: still overhead-dominated — compaction #1 was ineffective
// (streak 1) and compaction #2 runs.
bumpUsage(ctx, 1_800);
ctx.mockNextResponse({ type: 'text', text: 'Compacted summary two.' });
ctx.mockNextResponse({ type: 'text', text: 'Answer two.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'prompt two' }] });
await ctx.untilTurnEnd();

// Turn 3: this time the steady-state count stayed low after compaction
// #2, so the streak resets to 0 and no compaction runs.
ctx.mockNextResponse({ type: 'text', text: 'Answer three.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'prompt three' }] });
await ctx.untilTurnEnd();
expect(ctx.allEvents.filter((entry) => entry.event === 'warning')).toHaveLength(0);
expect(ctx.get(IAgentStateService).get(fullCompactionAutoCompactionDisabledKey)).toBe(false);

// Turns 4-6: two consecutive ineffective compactions (#3, #4) open the
// circuit — only possible because the turn-3 reset zeroed the streak.
bumpUsage(ctx, 1_800);
ctx.mockNextResponse({ type: 'text', text: 'Compacted summary three.' });
ctx.mockNextResponse({ type: 'text', text: 'Answer four.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'prompt four' }] });
await ctx.untilTurnEnd();

bumpUsage(ctx, 1_800);
ctx.mockNextResponse({ type: 'text', text: 'Compacted summary four.' });
ctx.mockNextResponse({ type: 'text', text: 'Answer five.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'prompt five' }] });
await ctx.untilTurnEnd();

bumpUsage(ctx, 1_800);
ctx.mockNextResponse({ type: 'text', text: 'Answer six.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'prompt six' }] });
await ctx.untilTurnEnd();

const compactionCalls = ctx.llmCalls.filter((call) =>
messageText(call.history.at(-1)).includes('first-person handoff note'),
);
expect(compactionCalls).toHaveLength(4);
expect(ctx.llmCalls).toHaveLength(10);
expect(ctx.allEvents.filter((entry) => entry.event === 'warning')).toHaveLength(1);
expect(ctx.get(IAgentStateService).get(fullCompactionAutoCompactionDisabledKey)).toBe(true);
await ctx.expectResumeMatches();
});

it('compacts and retries when the provider reports context overflow', async () => {
let callCount = 0;
const inputs: string[][] = [];
Expand Down Expand Up @@ -2963,6 +3089,21 @@ type MutableKimiConfig = {
};
};

// Simulates the server reporting a constant, overhead-dominated usage total
// for the current context — the trigger condition of the small-window
// auto-compaction loop from upstream issue #2325. Mirrors the harness's
// (private) coverUsage helper.
function bumpUsage(ctx: TestAgentContext, tokenTotal: number): void {
const contextSize = ctx.get(IAgentContextSizeService);
const contextMemory = ctx.get(IAgentContextMemoryService);
contextSize.measured(contextMemory.get(), [], {
inputOther: tokenTotal - 1,
output: 1,
inputCacheRead: 0,
inputCacheCreation: 0,
});
}

function textResult(text: string, traceId: string | null = null): Awaited<ReturnType<GenerateFn>> {
return {
id: 'mock-compaction-oauth-retry',
Expand Down
Loading