Skip to content

Commit c032fc2

Browse files
committed
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.
1 parent 2a96372 commit c032fc2

4 files changed

Lines changed: 484 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@kilocode/cli": patch
3+
---
4+
5+
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.

packages/opencode/src/kilocode/session/prompt.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,4 +165,37 @@ export namespace KiloSessionPrompt {
165165
}
166166
return "completed"
167167
}
168+
169+
/**
170+
* Maximum number of compactions attempted within a single turn before we
171+
* surface an exhaustion error. Three is enough to cover a normal overflow
172+
* compaction plus a summary-self-overflow retry without spinning forever.
173+
*/
174+
export const MAX_COMPACTION_ATTEMPTS = 3
175+
176+
/**
177+
* Guards a compaction attempt. When the attempt count has already reached
178+
* `MAX_COMPACTION_ATTEMPTS`, marks the close reason as `"error"`, attaches a
179+
* `ContextOverflowError` to the assistant message (if provided), and returns
180+
* `{ exhausted: true }` so callers can break out of the loop. Otherwise
181+
* returns `{ exhausted: false }`.
182+
*/
183+
export function guardCompactionAttempt(input: {
184+
sessionID: string
185+
attempts: number
186+
closeReasons: Map<string, KiloSession.CloseReason>
187+
message?: MessageV2.Assistant
188+
}) {
189+
if (input.attempts < MAX_COMPACTION_ATTEMPTS) return { exhausted: false as const }
190+
const error = new MessageV2.ContextOverflowError({
191+
message: `Compaction exhausted: context still exceeds model limits after ${MAX_COMPACTION_ATTEMPTS} attempts`,
192+
}).toObject()
193+
input.closeReasons.set(input.sessionID, "error")
194+
if (input.message) {
195+
// Preserve any pre-existing error/finish the caller already set; only fill in blanks.
196+
input.message.error ??= error
197+
input.message.finish ??= "error"
198+
}
199+
return { exhausted: true as const, error }
200+
}
168201
}

packages/opencode/src/session/prompt.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1343,6 +1343,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
13431343
// kilocode_change — cache environment details per turn (prompt caching)
13441344
const envCache: KiloSessionPrompt.EnvCache = {}
13451345
closeReasons.delete(sessionID) // kilocode_change
1346+
let compactionAttempts = 0 // kilocode_change - cap compaction attempts per turn to avoid infinite loops
13461347
const ctx = yield* InstanceState.context
13471348
const slog = elog.with({ sessionID })
13481349
let structured: unknown | undefined
@@ -1424,7 +1425,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
14241425
auto: task.auto,
14251426
overflow: task.overflow,
14261427
})
1427-
if (result === "stop") break
1428+
// kilocode_change start - compaction.process only returns "stop" after
1429+
// setting ContextOverflowError on the summary message; surface as turn error
1430+
if (result === "stop") {
1431+
closeReasons.set(sessionID, "error")
1432+
break
1433+
}
1434+
// kilocode_change end
14281435
continue
14291436
}
14301437

@@ -1433,6 +1440,22 @@ NOTE: At any point in time through this workflow you should feel free to ask the
14331440
lastFinished.summary !== true &&
14341441
(yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))
14351442
) {
1443+
// kilocode_change start
1444+
const guard = KiloSessionPrompt.guardCompactionAttempt({
1445+
sessionID,
1446+
attempts: compactionAttempts,
1447+
closeReasons,
1448+
message: lastFinished,
1449+
})
1450+
if (guard.exhausted) {
1451+
// lastFinished is a prior turn's assistant — record exhaustion on the
1452+
// message whose size tipped us past the compaction cap.
1453+
yield* sessions.updateMessage(lastFinished)
1454+
yield* bus.publish(Session.Event.Error, { sessionID, error: guard.error })
1455+
break
1456+
}
1457+
compactionAttempts++
1458+
// kilocode_change end
14361459
yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
14371460
continue
14381461
}
@@ -1570,6 +1593,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the
15701593
}
15711594
// kilocode_change end
15721595
if (result === "compact") {
1596+
// kilocode_change start
1597+
const guard = KiloSessionPrompt.guardCompactionAttempt({
1598+
sessionID,
1599+
attempts: compactionAttempts,
1600+
closeReasons,
1601+
message: handle.message,
1602+
})
1603+
if (guard.exhausted) {
1604+
yield* sessions.updateMessage(handle.message)
1605+
yield* bus.publish(Session.Event.Error, { sessionID, error: guard.error })
1606+
return "break" as const
1607+
}
1608+
compactionAttempts++
1609+
// kilocode_change end
15731610
yield* compaction.create({
15741611
sessionID,
15751612
agent: lastUser.agent,

0 commit comments

Comments
 (0)