fix(cli): cap per-turn compaction attempts to stop infinite busy loop

When every compaction round still overflowed the model context, SessionPrompt.runLoop would keep calling compaction forever and report the turn as completed. Cap attempts at three per turn and surface exhaustion as a ContextOverflowError on the assistant message with TurnClose reason=error.
This commit is contained in:
Alex Alecu
2026-04-22 09:54:11 +03:00
parent 2a963723cc
commit c032fc2021
4 changed files with 484 additions and 1 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Fix an infinite "busy" loop that could occur when a model kept reporting context overflow after every compaction. Each turn now caps compactions at three attempts and closes the turn with a visible context-overflow error instead of silently looping forever.
@@ -165,4 +165,37 @@ export namespace KiloSessionPrompt {
}
return "completed"
}
/**
* Maximum number of compactions attempted within a single turn before we
* surface an exhaustion error. Three is enough to cover a normal overflow
* compaction plus a summary-self-overflow retry without spinning forever.
*/
export const MAX_COMPACTION_ATTEMPTS = 3
/**
* Guards a compaction attempt. When the attempt count has already reached
* `MAX_COMPACTION_ATTEMPTS`, marks the close reason as `"error"`, attaches a
* `ContextOverflowError` to the assistant message (if provided), and returns
* `{ exhausted: true }` so callers can break out of the loop. Otherwise
* returns `{ exhausted: false }`.
*/
export function guardCompactionAttempt(input: {
sessionID: string
attempts: number
closeReasons: Map<string, KiloSession.CloseReason>
message?: MessageV2.Assistant
}) {
if (input.attempts < MAX_COMPACTION_ATTEMPTS) return { exhausted: false as const }
const error = new MessageV2.ContextOverflowError({
message: `Compaction exhausted: context still exceeds model limits after ${MAX_COMPACTION_ATTEMPTS} attempts`,
}).toObject()
input.closeReasons.set(input.sessionID, "error")
if (input.message) {
// Preserve any pre-existing error/finish the caller already set; only fill in blanks.
input.message.error ??= error
input.message.finish ??= "error"
}
return { exhausted: true as const, error }
}
}
+38 -1
View File
@@ -1343,6 +1343,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
// kilocode_change — cache environment details per turn (prompt caching)
const envCache: KiloSessionPrompt.EnvCache = {}
closeReasons.delete(sessionID) // kilocode_change
let compactionAttempts = 0 // kilocode_change - cap compaction attempts per turn to avoid infinite loops
const ctx = yield* InstanceState.context
const slog = elog.with({ sessionID })
let structured: unknown | undefined
@@ -1424,7 +1425,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
auto: task.auto,
overflow: task.overflow,
})
if (result === "stop") break
// kilocode_change start - compaction.process only returns "stop" after
// setting ContextOverflowError on the summary message; surface as turn error
if (result === "stop") {
closeReasons.set(sessionID, "error")
break
}
// kilocode_change end
continue
}
@@ -1433,6 +1440,22 @@ NOTE: At any point in time through this workflow you should feel free to ask the
lastFinished.summary !== true &&
(yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))
) {
// kilocode_change start
const guard = KiloSessionPrompt.guardCompactionAttempt({
sessionID,
attempts: compactionAttempts,
closeReasons,
message: lastFinished,
})
if (guard.exhausted) {
// lastFinished is a prior turn's assistant — record exhaustion on the
// message whose size tipped us past the compaction cap.
yield* sessions.updateMessage(lastFinished)
yield* bus.publish(Session.Event.Error, { sessionID, error: guard.error })
break
}
compactionAttempts++
// kilocode_change end
yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
continue
}
@@ -1570,6 +1593,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}
// kilocode_change end
if (result === "compact") {
// kilocode_change start
const guard = KiloSessionPrompt.guardCompactionAttempt({
sessionID,
attempts: compactionAttempts,
closeReasons,
message: handle.message,
})
if (guard.exhausted) {
yield* sessions.updateMessage(handle.message)
yield* bus.publish(Session.Event.Error, { sessionID, error: guard.error })
return "break" as const
}
compactionAttempts++
// kilocode_change end
yield* compaction.create({
sessionID,
agent: lastUser.agent,
@@ -0,0 +1,408 @@
// Regressions for the MAX_COMPACTION_ATTEMPTS cap in SessionPrompt.runLoop.
// Ensures the loop cannot spin forever when every compaction round still
// overflows the model context, and that the exhausted turn surfaces as an
// error (rather than silently completing).
import { NodeFileSystem } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Command } from "../../src/command"
import { Config } from "../../src/config/config"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Env } from "../../src/env"
import { FileTime } from "../../src/file/time"
import { Ripgrep } from "../../src/file/ripgrep"
import { AppFileSystem } from "../../src/filesystem"
import { Format } from "../../src/format"
import { KiloSession } from "../../src/kilocode/session"
import { KiloSessionPrompt } from "../../src/kilocode/session/prompt"
import { LSP } from "../../src/lsp"
import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Question } from "../../src/question"
import { Session } from "../../src/session"
import { SessionCompaction } from "../../src/session/compaction"
import { Instruction } from "../../src/session/instruction"
import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionProcessor } from "../../src/session/processor"
import { SessionPrompt } from "../../src/session/prompt"
import { SessionRevert } from "../../src/session/revert"
import { SessionRunState } from "../../src/session/run-state"
import { MessageID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { SystemPrompt } from "../../src/session/system"
import { SessionSummary } from "../../src/session/summary"
import { Todo } from "../../src/session/todo"
import { Skill } from "../../src/skill"
import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate"
import { Log } from "../../src/util/log"
import { provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestLLMServer } from "../lib/llm-server"
Log.init({ print: false })
const ref = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test-model"),
}
const summary = Layer.succeed(
SessionSummary.Service,
SessionSummary.Service.of({
summarize: () => Effect.void,
diff: () => Effect.succeed([]),
computeDiff: () => Effect.succeed([]),
}),
)
// Pass-through plugin mock. Lets every plugin trigger proceed with its default
// output so compaction's `experimental.compaction.autocontinue` stays on (the
// "compact" result path uses replay mode and the loop re-enters without the
// synthetic continue prompt anyway).
const plugin = Layer.mock(Plugin.Service)({
trigger: <Name extends string, Input, Output>(_name: Name, _input: Input, output: Output) => Effect.succeed(output),
list: () => Effect.succeed([]),
init: () => Effect.void,
})
const mcp = Layer.succeed(
MCP.Service,
MCP.Service.of({
status: () => Effect.succeed({}),
clients: () => Effect.succeed({}),
tools: () => Effect.succeed({}),
prompts: () => Effect.succeed({}),
resources: () => Effect.succeed({}),
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
connect: () => Effect.void,
disconnect: () => Effect.void,
getPrompt: () => Effect.succeed(undefined),
readResource: () => Effect.succeed(undefined),
startAuth: () => Effect.die("unexpected MCP auth in compaction cap tests"),
authenticate: () => Effect.die("unexpected MCP auth in compaction cap tests"),
finishAuth: () => Effect.die("unexpected MCP auth in compaction cap tests"),
removeAuth: () => Effect.void,
supportsOAuth: () => Effect.succeed(false),
hasStoredTokens: () => Effect.succeed(false),
getAuthStatus: () => Effect.succeed("not_authenticated" as const),
}),
)
const lsp = Layer.succeed(
LSP.Service,
LSP.Service.of({
init: () => Effect.void,
status: () => Effect.succeed([]),
hasClients: () => Effect.succeed(false),
touchFile: () => Effect.void,
diagnostics: () => Effect.succeed({}),
hover: () => Effect.succeed(undefined),
definition: () => Effect.succeed([]),
references: () => Effect.succeed([]),
implementation: () => Effect.succeed([]),
documentSymbol: () => Effect.succeed([]),
workspaceSymbol: () => Effect.succeed([]),
prepareCallHierarchy: () => Effect.succeed([]),
incomingCalls: () => Effect.succeed([]),
outgoingCalls: () => Effect.succeed([]),
}),
)
const filetime = Layer.succeed(
FileTime.Service,
FileTime.Service.of({
read: () => Effect.void,
get: () => Effect.succeed(undefined),
assert: () => Effect.void,
withLock: (_filepath, fn) => fn(),
}),
)
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
const runState = SessionRunState.layer.pipe(Layer.provide(status))
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
function makeHttp() {
const deps = Layer.mergeAll(
Session.defaultLayer,
Snapshot.defaultLayer,
LLM.defaultLayer,
Env.defaultLayer,
AgentSvc.defaultLayer,
Command.defaultLayer,
Permission.defaultLayer,
plugin,
Config.defaultLayer,
ProviderSvc.defaultLayer,
filetime,
lsp,
mcp,
AppFileSystem.defaultLayer,
status,
).pipe(Layer.provideMerge(infra))
const question = Question.layer.pipe(Layer.provideMerge(deps))
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),
)
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps))
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
return Layer.mergeAll(
TestLLMServer.layer,
SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(summary),
Layer.provideMerge(runState),
Layer.provideMerge(compact),
Layer.provideMerge(proc),
Layer.provideMerge(registry),
Layer.provideMerge(trunc),
Layer.provide(Instruction.defaultLayer),
Layer.provide(SystemPrompt.defaultLayer),
Layer.provideMerge(deps),
),
).pipe(Layer.provide(summary))
}
const it = testEffect(makeHttp())
const cfg = {
provider: {
test: {
name: "Test",
id: "test",
env: [],
npm: "@ai-sdk/openai-compatible",
models: {
"test-model": {
id: "test-model",
name: "Test Model",
attachment: false,
reasoning: false,
temperature: false,
tool_call: true,
release_date: "2025-01-01",
limit: { context: 100000, output: 10000 },
cost: { input: 0, output: 0 },
options: {},
},
},
options: {
apiKey: "test-key",
baseURL: "http://localhost:1/v1",
},
},
},
}
function providerCfg(url: string) {
return {
...cfg,
provider: {
...cfg.provider,
test: {
...cfg.provider.test,
options: {
...cfg.provider.test.options,
baseURL: url,
},
},
},
}
}
const overflowBody = { type: "error", error: { code: "context_length_exceeded" } }
describe("session compaction cap", () => {
it.live(
"closes the turn with reason=error after MAX_COMPACTION_ATTEMPTS compactions",
() =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const bus = yield* Bus.Service
const chat = yield* sessions.create({
title: "Compaction cap",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
// Interleave overflow errors (top-level LLM call) and successful
// summary texts (compaction.process internal LLM call) so each
// compaction round completes and the loop re-enters. With
// MAX_COMPACTION_ATTEMPTS = 3 we queue 4 errors + 3 texts = 7 calls.
// The final error triggers guardCompactionAttempt and breaks.
yield* llm.error(400, overflowBody) // 1 — top-level fails → attempt 1
yield* llm.text("summary 1") // 2 — compaction summary succeeds
yield* llm.error(400, overflowBody) // 3 — post-replay fails → attempt 2
yield* llm.text("summary 2") // 4
yield* llm.error(400, overflowBody) // 5 — attempt 3
yield* llm.text("summary 3") // 6
yield* llm.error(400, overflowBody) // 7 — exhausts, breaks
const turnClose = yield* Deferred.make<KiloSession.CloseReason>()
const unsub = yield* bus.subscribeCallback(KiloSession.Event.TurnClose, (evt) => {
if (evt.properties.sessionID === chat.id)
Deferred.doneUnsafe(turnClose, Effect.succeed(evt.properties.reason))
})
yield* prompt.prompt({
sessionID: chat.id,
agent: "code",
noReply: true,
parts: [{ type: "text", text: "please overflow" }],
})
const result = yield* prompt.loop({ sessionID: chat.id })
const reason = yield* Deferred.await(turnClose).pipe(Effect.timeout("2 seconds"))
unsub()
// Each compaction round costs 2 LLM calls in this replay-mode path (one
// top-level overflow + one summary) plus 1 final overflow that trips the cap.
expect(yield* llm.calls).toBe(KiloSessionPrompt.MAX_COMPACTION_ATTEMPTS * 2 + 1)
expect(reason).toBe("error")
expect(result.info.role).toBe("assistant")
if (result.info.role !== "assistant") return
expect(result.info.finish).toBe("error")
expect(result.info.error?.name).toBe("ContextOverflowError")
if (result.info.error?.name !== "ContextOverflowError") return
expect(result.info.error.data.message).toContain("Compaction exhausted")
}),
{ git: true, config: providerCfg },
),
30_000,
)
it.live(
"completes normally when compactions stay below the cap",
() =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const bus = yield* Bus.Service
const chat = yield* sessions.create({
title: "Compaction under cap",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* llm.error(400, overflowBody) // 1 — one compaction attempt
yield* llm.text("summary ok") // 2 — summary succeeds
yield* llm.text("final answer") // 3 — replayed turn completes
const turnClose = yield* Deferred.make<KiloSession.CloseReason>()
const unsub = yield* bus.subscribeCallback(KiloSession.Event.TurnClose, (evt) => {
if (evt.properties.sessionID === chat.id)
Deferred.doneUnsafe(turnClose, Effect.succeed(evt.properties.reason))
})
yield* prompt.prompt({
sessionID: chat.id,
agent: "code",
noReply: true,
parts: [{ type: "text", text: "overflow once" }],
})
const result = yield* prompt.loop({ sessionID: chat.id })
const reason = yield* Deferred.await(turnClose).pipe(Effect.timeout("2 seconds"))
unsub()
expect(yield* llm.calls).toBe(3)
expect(reason).toBe("completed")
expect(result.info.role).toBe("assistant")
if (result.info.role !== "assistant") return
expect(result.info.finish).toBe("stop")
expect(result.info.error).toBeUndefined()
expect(result.parts.some((p) => p.type === "text" && p.text === "final answer")).toBe(true)
}),
{ git: true, config: providerCfg },
),
15_000,
)
})
function makeAssistantStub(sessionID: string): MessageV2.Assistant {
return {
id: MessageID.ascending(),
role: "assistant",
sessionID: SessionID.make(sessionID),
parentID: MessageID.ascending(),
mode: "code",
agent: "code",
cost: 0,
path: { cwd: "/tmp", root: "/tmp" },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: ref.modelID,
providerID: ref.providerID,
time: { created: Date.now() },
}
}
describe("KiloSessionPrompt.guardCompactionAttempt", () => {
it.effect("returns { exhausted: false } and does not mutate state below the cap", () =>
Effect.sync(() => {
const closeReasons = new Map<string, KiloSession.CloseReason>()
const msg = makeAssistantStub("ses_under")
const result = KiloSessionPrompt.guardCompactionAttempt({
sessionID: "ses_under",
attempts: KiloSessionPrompt.MAX_COMPACTION_ATTEMPTS - 1,
closeReasons,
message: msg,
})
expect(result.exhausted).toBe(false)
expect(closeReasons.has("ses_under")).toBe(false)
expect(msg.error).toBeUndefined()
expect(msg.finish).toBeUndefined()
}),
)
it.effect("sets close reason and attaches error once attempts reach the cap", () =>
Effect.sync(() => {
const closeReasons = new Map<string, KiloSession.CloseReason>()
const msg = makeAssistantStub("ses_cap")
const result = KiloSessionPrompt.guardCompactionAttempt({
sessionID: "ses_cap",
attempts: KiloSessionPrompt.MAX_COMPACTION_ATTEMPTS,
closeReasons,
message: msg,
})
expect(result.exhausted).toBe(true)
if (!result.exhausted) return
expect(closeReasons.get("ses_cap")).toBe("error")
expect(msg.error?.name).toBe("ContextOverflowError")
if (msg.error?.name !== "ContextOverflowError") return
expect(msg.error.data.message).toContain("Compaction exhausted")
expect(msg.finish).toBe("error")
expect(result.error.name).toBe("ContextOverflowError")
}),
)
it.effect("works without a message and still sets the close reason", () =>
Effect.sync(() => {
const closeReasons = new Map<string, KiloSession.CloseReason>()
const result = KiloSessionPrompt.guardCompactionAttempt({
sessionID: "ses_no_msg",
attempts: KiloSessionPrompt.MAX_COMPACTION_ATTEMPTS,
closeReasons,
})
expect(result.exhausted).toBe(true)
expect(closeReasons.get("ses_no_msg")).toBe("error")
}),
)
})