Skip to content

Commit c9e0d76

Browse files
authored
fix(session): bound the retry loop, fix the doom-loop guard, treat abort as a clean stop (#124)
- Cap transient-error retries at MAX_RETRY_ATTEMPTS (10) per message generation. The while(true) retry loop was unbounded, and retry.ts marks any JSON body with an 'error' field as retryable, so a persistently-failing provider (or a permanent error arriving as JSON) retried forever. (#7) - Doom-loop detection now scans the last N *tool* parts (isDoomLoop helper) instead of the last N raw parts. Reasoning models emit a reasoning part before each tool call, so the last 3 raw parts were never 3 identical tool parts and the guard never fired against a genuinely looping tool. (#10) - A user-initiated abort no longer fires the session Error event: it's a clean cancellation, so record it on the message (turn still stops) but don't surface it as an error state in the UI. (#51)
1 parent abe76c6 commit c9e0d76

2 files changed

Lines changed: 83 additions & 16 deletions

File tree

backend/cli/src/session/processor.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,32 @@ import { requiresWalletBalance, shouldReportUsage, resolveCredentialSource, llmB
2020

2121
export namespace SessionProcessor {
2222
const DOOM_LOOP_THRESHOLD = 3
23+
// Hard ceiling on transient-error retries within a single message generation.
24+
// The retry loop is otherwise unbounded, and retry.ts classifies any JSON
25+
// body carrying an `error` field as retryable — so a persistently-failing
26+
// provider (or a permanent error arriving as JSON) looped forever.
27+
const MAX_RETRY_ATTEMPTS = 10
2328
const log = Log.create({ service: "session.processor" })
2429

30+
/** True when the last `threshold` TOOL calls are the same tool with the same
31+
* input, ignoring reasoning/text/step parts interleaved between them. A naive
32+
* "last N raw parts" check was defeated by reasoning models, which emit a
33+
* reasoning part before each tool call, so the doom-loop guard never fired. */
34+
export function isDoomLoop(
35+
parts: MessageV2.Part[],
36+
toolName: string,
37+
input: unknown,
38+
threshold = DOOM_LOOP_THRESHOLD,
39+
): boolean {
40+
const tools = parts.filter((p): p is MessageV2.ToolPart => p.type === "tool")
41+
const last = tools.slice(-threshold)
42+
if (last.length < threshold) return false
43+
return last.every(
44+
(p) =>
45+
p.tool === toolName && p.state.status !== "pending" && JSON.stringify(p.state.input) === JSON.stringify(input),
46+
)
47+
}
48+
2549
export type Info = Awaited<ReturnType<typeof create>>
2650
export type Result = Awaited<ReturnType<Info["process"]>>
2751

@@ -182,18 +206,8 @@ export namespace SessionProcessor {
182206
toolcalls[value.toolCallId] = part as MessageV2.ToolPart
183207

184208
const parts = await MessageV2.parts(input.assistantMessage.id)
185-
const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD)
186209

187-
if (
188-
lastThree.length === DOOM_LOOP_THRESHOLD &&
189-
lastThree.every(
190-
(p) =>
191-
p.type === "tool" &&
192-
p.tool === value.toolName &&
193-
p.state.status !== "pending" &&
194-
JSON.stringify(p.state.input) === JSON.stringify(value.input),
195-
)
196-
) {
210+
if (isDoomLoop(parts, value.toolName, value.input)) {
197211
const agent = await Agent.get(input.assistantMessage.agent)
198212
await PermissionNext.ask({
199213
permission: "doom_loop",
@@ -419,7 +433,7 @@ export namespace SessionProcessor {
419433
})
420434
const error = MessageV2.fromError(e, { providerID: input.model.providerID })
421435
const retry = SessionRetry.retryable(error)
422-
if (retry !== undefined) {
436+
if (retry !== undefined && attempt < MAX_RETRY_ATTEMPTS) {
423437
attempt++
424438
const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
425439
SessionStatus.set(input.sessionID, {
@@ -432,10 +446,15 @@ export namespace SessionProcessor {
432446
continue
433447
}
434448
input.assistantMessage.error = error
435-
Bus.publish(Session.Event.Error, {
436-
sessionID: input.assistantMessage.sessionID,
437-
error: input.assistantMessage.error,
438-
})
449+
// A user-initiated abort is a clean cancellation, not a failure —
450+
// record it on the message (so the turn stops) but don't fire the
451+
// session Error event the UI renders as an error state.
452+
if (!MessageV2.AbortedError.isInstance(error)) {
453+
Bus.publish(Session.Event.Error, {
454+
sessionID: input.assistantMessage.sessionID,
455+
error: input.assistantMessage.error,
456+
})
457+
}
439458
}
440459
if (snapshot) {
441460
const patch = await Snapshot.patch(snapshot)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { SessionProcessor } from "../../src/session/processor"
3+
4+
const tool = (name: string, input: unknown, status = "completed"): any => ({
5+
type: "tool",
6+
tool: name,
7+
callID: "c",
8+
state: { status, input },
9+
})
10+
const reasoning = (): any => ({ type: "reasoning", text: "thinking..." })
11+
const text = (): any => ({ type: "text", text: "hi" })
12+
13+
describe("SessionProcessor.isDoomLoop", () => {
14+
test("fires when the last 3 TOOL calls are identical, even with reasoning/text between them", () => {
15+
// A reasoning model interleaves a reasoning part before every tool call —
16+
// the old last-3-raw-parts check never saw 3 consecutive tool parts.
17+
const parts = [
18+
reasoning(),
19+
tool("bash", { cmd: "ls" }),
20+
reasoning(),
21+
tool("bash", { cmd: "ls" }),
22+
text(),
23+
reasoning(),
24+
tool("bash", { cmd: "ls" }),
25+
]
26+
expect(SessionProcessor.isDoomLoop(parts, "bash", { cmd: "ls" })).toBe(true)
27+
})
28+
29+
test("does not fire when the inputs differ", () => {
30+
const parts = [tool("bash", { cmd: "a" }), tool("bash", { cmd: "b" }), tool("bash", { cmd: "c" })]
31+
expect(SessionProcessor.isDoomLoop(parts, "bash", { cmd: "c" })).toBe(false)
32+
})
33+
34+
test("does not fire below the threshold of 3 tool calls", () => {
35+
const parts = [reasoning(), tool("bash", { cmd: "ls" }), reasoning(), tool("bash", { cmd: "ls" })]
36+
expect(SessionProcessor.isDoomLoop(parts, "bash", { cmd: "ls" })).toBe(false)
37+
})
38+
39+
test("does not fire when a different tool breaks the streak", () => {
40+
const parts = [tool("bash", { cmd: "ls" }), tool("read", { path: "x" }), tool("bash", { cmd: "ls" })]
41+
expect(SessionProcessor.isDoomLoop(parts, "bash", { cmd: "ls" })).toBe(false)
42+
})
43+
44+
test("ignores a pending tool call (not yet a confirmed repeat)", () => {
45+
const parts = [tool("bash", { cmd: "ls" }), tool("bash", { cmd: "ls" }), tool("bash", { cmd: "ls" }, "pending")]
46+
expect(SessionProcessor.isDoomLoop(parts, "bash", { cmd: "ls" })).toBe(false)
47+
})
48+
})

0 commit comments

Comments
 (0)