From 33fd81b281373f04acfc665013d8a05700f6edd6 Mon Sep 17 00:00:00 2001 From: xy200303 <3483421977@qq.com> Date: Sat, 1 Aug 2026 11:49:16 +0800 Subject: [PATCH 1/2] fix(agent-core): stop looping auto-compaction past the unreachable threshold When a model's context window is small relative to the CLI's fixed request overhead (system prompt + tool schemas, ~29k tokens), the auto-compaction trigger threshold sits below the baseline request size: every compaction only shrinks conversation messages, and the next server-reported usage lands back over the threshold, so auto-compaction repeats forever (reported with self-hosted openai_responses models, e.g. a 32k max_context_size where 0.85 * window < 29k baseline). Add a circuit breaker: each auto-compaction records the token count that triggered it, and the next check (after a real step re-measures the request) compares the steady-state count against that baseline. A genuine drop below 80% of the baseline resets the streak; otherwise it grows, and at two consecutive ineffective compactions auto-compaction is disabled for the rest of the session with a warning pointing at max_context_size and loop_control settings, instead of looping. Manual compaction and overflow recovery (capped at 3 attempts) bypass the circuit. Refs #2325 --- .changeset/auto-compaction-circuit-breaker.md | 5 + .../fullCompaction/fullCompactionService.ts | 104 ++++++++++++- .../fullCompaction/fullCompaction.test.ts | 141 ++++++++++++++++++ .../agent-core/src/agent/compaction/full.ts | 73 ++++++++- .../test/agent/compaction/full.test.ts | 55 +++++++ 5 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 .changeset/auto-compaction-circuit-breaker.md diff --git a/.changeset/auto-compaction-circuit-breaker.md b/.changeset/auto-compaction-circuit-breaker.md new file mode 100644 index 0000000000..0fa69f55ea --- /dev/null +++ b/.changeset/auto-compaction-circuit-breaker.md @@ -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` or a lower `loop_control.reserved_context_size` / `compaction_trigger_ratio`) instead of compacting on every step. diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 6685dde17b..0bd8fc639c 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -7,7 +7,9 @@ * context-overflow failures by blocking the turn on the in-flight job. 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 @@ -89,6 +91,14 @@ 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; +// A compaction is "ineffective" when the steady-state token count measured +// after it stays >= this ratio of the count that triggered it — the trigger +// is then driven by the fixed request overhead (system prompt, tool +// schemas), which compaction cannot shrink. Two in a row mean auto-compaction +// can never succeed for this model config, so it is disabled for the rest of +// the session instead of looping forever. +const INEFFECTIVE_AUTO_COMPACTION_RATIO = 0.8; +const MAX_INEFFECTIVE_AUTO_COMPACTIONS = 2; const EMPTY_TOOL_PARAMETERS: Record = { type: 'object', properties: {}, @@ -138,6 +148,18 @@ export const fullCompactionActiveTurnIdKey = defineState( 'fullCompaction.activeTurnId', () => undefined as number | undefined, ); +export const fullCompactionPendingAutoCompactionCountKey = defineState( + 'fullCompaction.pendingAutoCompactionCount', + () => null, +); +export const fullCompactionIneffectiveAutoCompactionsKey = defineState( + 'fullCompaction.ineffectiveAutoCompactions', + () => 0, +); +export const fullCompactionAutoCompactionDisabledKey = defineState( + 'fullCompaction.autoCompactionDisabled', + () => false, +); export class AgentFullCompactionService extends Disposable implements IAgentFullCompactionService { declare readonly _serviceBrand: undefined; @@ -173,6 +195,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); this.strategy = new RuntimeCompactionStrategy(() => this.resolveModelContextWithEffectiveMax()); this._register( this.wire.hooks.onDidRestore.register('full-compaction', async (_ctx, next) => { @@ -245,6 +270,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; } @@ -487,14 +536,61 @@ 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; + } + + // Circuit breaker for ineffective auto-compactions. When the fixed request + // overhead (system prompt, tool schemas) alone exceeds the trigger + // threshold — e.g. a self-hosted model whose max_context_size is small + // relative to the CLI's baseline — every auto-compaction shrinks only the + // conversation messages and the next server-reported usage jumps straight + // back over the threshold, looping forever. Each auto-compaction records + // the count that triggered it (`pendingAutoCompactionCount`); the next + // check compares the steady-state count against that baseline. A genuine + // drop below INEFFECTIVE_AUTO_COMPACTION_RATIO of the baseline resets the + // streak; otherwise the streak grows, and at MAX_INEFFECTIVE_AUTO_COMPACTIONS + // auto-compaction is disabled for the rest of the session. This runs on + // every checkAutoCompaction call, before the shouldCompact gate, so a legit + // compaction that brought the count back under the threshold also clears + // the baseline and the next genuine trigger is not misjudged. The streak + // deliberately spans turns (resetForTurn does not touch it): ineffectiveness + // is a property of the model config, not of one turn. Manual compaction and + // overflow recovery bypass the circuit — they never set the baseline. + 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 or lower loop_control.reserved_context_size / 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 { diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index d2f0490542..1dafa08a07 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -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'; @@ -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'; @@ -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[][] = []; @@ -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> { return { id: 'mock-compaction-oauth-retry', diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index f56c7bc919..cc09779f95 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -59,6 +59,14 @@ export const MAX_COMPACTION_RETRY_ATTEMPTS = 5; const DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS = 128 * 1024; const OVERFLOW_CONTEXT_SAFETY_RATIO = 0.85; const OVERFLOW_STATUS_RECOVERY_RATIO = 0.5; +// A compaction is "ineffective" when the steady-state token count measured +// after it stays >= this ratio of the count that triggered it — the trigger +// is then driven by the fixed request overhead (system prompt, tool +// schemas), which compaction cannot shrink. Two in a row mean auto-compaction +// can never succeed for this model config, so it is disabled for the rest of +// the session instead of looping forever. +const INEFFECTIVE_AUTO_COMPACTION_RATIO = 0.8; +const MAX_INEFFECTIVE_AUTO_COMPACTIONS = 2; class CompactionTruncatedError extends Error { constructor() { @@ -88,6 +96,15 @@ export class FullCompaction { // stop an overflow -> compact -> overflow loop when compaction can no // longer shrink the request below the model window. private consecutiveOverflowCompactions = 0; + // Circuit-breaker state for ineffective auto-compactions (see + // evaluateAutoCompactionEffectiveness). `pendingAutoCompactionCount` is the + // token count that triggered the last auto-compaction, awaiting an + // effectiveness evaluation at the next check. The streak and the disabled + // flag deliberately span turns (resetForTurn does not touch them): + // ineffectiveness is a property of the model config, not of one turn. + private pendingAutoCompactionCount: number | null = null; + private ineffectiveAutoCompactions = 0; + private autoCompactionDisabled = false; // Trace id (`x-trace-id`, Kimi/KFC only) of the latest summarizer request, // updated on every attempt — success or failure — so a compaction cancelled // mid-request can still be attributed to its server-side request. @@ -287,14 +304,64 @@ export class FullCompaction { private checkAutoCompaction(throwOnLimit: boolean = 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; + } + + // Circuit breaker for ineffective auto-compactions. When the fixed request + // overhead (system prompt, tool schemas) alone exceeds the trigger + // threshold — e.g. a self-hosted model whose max_context_size is small + // relative to the CLI's baseline — every auto-compaction shrinks only the + // conversation messages and the next server-reported usage jumps straight + // back over the threshold, looping forever. Each auto-compaction records + // the count that triggered it (`pendingAutoCompactionCount`); the next + // check compares the steady-state count against that baseline. A genuine + // drop below INEFFECTIVE_AUTO_COMPACTION_RATIO of the baseline resets the + // streak; otherwise the streak grows, and at MAX_INEFFECTIVE_AUTO_COMPACTIONS + // auto-compaction is disabled for the rest of the session. This runs on + // every checkAutoCompaction call, before the shouldCompact gate, so a legit + // compaction that brought the count back under the threshold also clears + // the baseline and the next genuine trigger is not misjudged. Manual + // compaction and overflow recovery bypass the circuit — they never set the + // baseline. + 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 or lower loop_control.reserved_context_size / compaction_trigger_ratio.`; + try { + this.agent.log.warn(message, { usedSize, maxContextTokens }); + } catch { + // Diagnostics must never break the compaction check. + } + try { + this.agent.emitEvent({ type: 'warning', code: 'auto-compaction-ineffective', message }); + } catch { + // Diagnostics must never break the compaction check. + } } private beginAutoCompaction(throwOnLimit: boolean = true): boolean { diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index abc4138dfa..0782a39119 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -1688,6 +1688,61 @@ 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. + ctx.appendExchange(2, 'bump user two', 'bump assistant two', 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. + ctx.appendExchange(3, 'bump user three', 'bump assistant three', 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'), + }), + ); + await ctx.expectResumeMatches(); + }); + it('keeps an oversized pending user prompt out of auto compaction', async () => { const ctx = testAgent(); ctx.configure({ From 17cd2c786d6d960f0023726d4cd57dfada105c6f Mon Sep 17 00:00:00 2001 From: xy200303 <3483421977@qq.com> Date: Sat, 1 Aug 2026 12:19:10 +0800 Subject: [PATCH 2/2] fix(agent-core): address auto-compaction review feedback --- .changeset/auto-compaction-circuit-breaker.md | 2 +- .../agent-core-v2/docs/state-manifest.d.ts | 8 +++++- .../fullCompaction/fullCompactionService.ts | 27 +++---------------- .../agent-core/src/agent/compaction/full.ts | 2 +- 4 files changed, 12 insertions(+), 27 deletions(-) diff --git a/.changeset/auto-compaction-circuit-breaker.md b/.changeset/auto-compaction-circuit-breaker.md index 0fa69f55ea..332ebdd0b8 100644 --- a/.changeset/auto-compaction-circuit-breaker.md +++ b/.changeset/auto-compaction-circuit-breaker.md @@ -2,4 +2,4 @@ "@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` or a lower `loop_control.reserved_context_size` / `compaction_trigger_ratio`) instead of compacting on every step. +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. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index d6ed6bd8bc..f9b77c410b 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -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 @@ -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 @@ -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; + 'fullCompaction.pendingAutoCompactionCount': number | null; // src/agent/goal/goalService.ts 'goal.budgetGraceTurns': Set; 'goal.countedGoalTurns': Set; diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 0bd8fc639c..0c40e66529 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -5,6 +5,8 @@ * 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`, @@ -91,12 +93,6 @@ 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; -// A compaction is "ineffective" when the steady-state token count measured -// after it stays >= this ratio of the count that triggered it — the trigger -// is then driven by the fixed request overhead (system prompt, tool -// schemas), which compaction cannot shrink. Two in a row mean auto-compaction -// can never succeed for this model config, so it is disabled for the rest of -// the session instead of looping forever. const INEFFECTIVE_AUTO_COMPACTION_RATIO = 0.8; const MAX_INEFFECTIVE_AUTO_COMPACTIONS = 2; const EMPTY_TOOL_PARAMETERS: Record = { @@ -551,23 +547,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull return began; } - // Circuit breaker for ineffective auto-compactions. When the fixed request - // overhead (system prompt, tool schemas) alone exceeds the trigger - // threshold — e.g. a self-hosted model whose max_context_size is small - // relative to the CLI's baseline — every auto-compaction shrinks only the - // conversation messages and the next server-reported usage jumps straight - // back over the threshold, looping forever. Each auto-compaction records - // the count that triggered it (`pendingAutoCompactionCount`); the next - // check compares the steady-state count against that baseline. A genuine - // drop below INEFFECTIVE_AUTO_COMPACTION_RATIO of the baseline resets the - // streak; otherwise the streak grows, and at MAX_INEFFECTIVE_AUTO_COMPACTIONS - // auto-compaction is disabled for the rest of the session. This runs on - // every checkAutoCompaction call, before the shouldCompact gate, so a legit - // compaction that brought the count back under the threshold also clears - // the baseline and the next genuine trigger is not misjudged. The streak - // deliberately spans turns (resetForTurn does not touch it): ineffectiveness - // is a property of the model config, not of one turn. Manual compaction and - // overflow recovery bypass the circuit — they never set the baseline. private evaluateAutoCompactionEffectiveness(usedSize: number): void { const baseline = this.pendingAutoCompactionCount; if (baseline === null) return; @@ -584,7 +563,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull `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 or lower loop_control.reserved_context_size / compaction_trigger_ratio.`; + `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 }); diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index cc09779f95..cd6d1edcd6 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -351,7 +351,7 @@ export class FullCompaction { `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 or lower loop_control.reserved_context_size / compaction_trigger_ratio.`; + `increase the model's max_context_size, lower loop_control.reserved_context_size, or raise loop_control.compaction_trigger_ratio.`; try { this.agent.log.warn(message, { usedSize, maxContextTokens }); } catch {