Skip to content
Merged
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
51 changes: 35 additions & 16 deletions backend/cli/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,32 @@ import { requiresWalletBalance, shouldReportUsage, resolveCredentialSource, llmB

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

/** True when the last `threshold` TOOL calls are the same tool with the same
* input, ignoring reasoning/text/step parts interleaved between them. A naive
* "last N raw parts" check was defeated by reasoning models, which emit a
* reasoning part before each tool call, so the doom-loop guard never fired. */
export function isDoomLoop(
parts: MessageV2.Part[],
toolName: string,
input: unknown,
threshold = DOOM_LOOP_THRESHOLD,
): boolean {
const tools = parts.filter((p): p is MessageV2.ToolPart => p.type === "tool")
const last = tools.slice(-threshold)
if (last.length < threshold) return false
return last.every(
(p) =>
p.tool === toolName && p.state.status !== "pending" && JSON.stringify(p.state.input) === JSON.stringify(input),
)
}

export type Info = Awaited<ReturnType<typeof create>>
export type Result = Awaited<ReturnType<Info["process"]>>

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

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

if (
lastThree.length === DOOM_LOOP_THRESHOLD &&
lastThree.every(
(p) =>
p.type === "tool" &&
p.tool === value.toolName &&
p.state.status !== "pending" &&
JSON.stringify(p.state.input) === JSON.stringify(value.input),
)
) {
if (isDoomLoop(parts, value.toolName, value.input)) {
const agent = await Agent.get(input.assistantMessage.agent)
await PermissionNext.ask({
permission: "doom_loop",
Expand Down Expand Up @@ -419,7 +433,7 @@ export namespace SessionProcessor {
})
const error = MessageV2.fromError(e, { providerID: input.model.providerID })
const retry = SessionRetry.retryable(error)
if (retry !== undefined) {
if (retry !== undefined && attempt < MAX_RETRY_ATTEMPTS) {
attempt++
const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
SessionStatus.set(input.sessionID, {
Expand All @@ -432,10 +446,15 @@ export namespace SessionProcessor {
continue
}
input.assistantMessage.error = error
Bus.publish(Session.Event.Error, {
sessionID: input.assistantMessage.sessionID,
error: input.assistantMessage.error,
})
// A user-initiated abort is a clean cancellation, not a failure —
// record it on the message (so the turn stops) but don't fire the
// session Error event the UI renders as an error state.
if (!MessageV2.AbortedError.isInstance(error)) {
Bus.publish(Session.Event.Error, {
sessionID: input.assistantMessage.sessionID,
error: input.assistantMessage.error,
})
}
}
if (snapshot) {
const patch = await Snapshot.patch(snapshot)
Expand Down
48 changes: 48 additions & 0 deletions backend/cli/test/session/doom-loop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test"
import { SessionProcessor } from "../../src/session/processor"

const tool = (name: string, input: unknown, status = "completed"): any => ({
type: "tool",
tool: name,
callID: "c",
state: { status, input },
})
const reasoning = (): any => ({ type: "reasoning", text: "thinking..." })
const text = (): any => ({ type: "text", text: "hi" })

describe("SessionProcessor.isDoomLoop", () => {
test("fires when the last 3 TOOL calls are identical, even with reasoning/text between them", () => {
// A reasoning model interleaves a reasoning part before every tool call —
// the old last-3-raw-parts check never saw 3 consecutive tool parts.
const parts = [
reasoning(),
tool("bash", { cmd: "ls" }),
reasoning(),
tool("bash", { cmd: "ls" }),
text(),
reasoning(),
tool("bash", { cmd: "ls" }),
]
expect(SessionProcessor.isDoomLoop(parts, "bash", { cmd: "ls" })).toBe(true)
})

test("does not fire when the inputs differ", () => {
const parts = [tool("bash", { cmd: "a" }), tool("bash", { cmd: "b" }), tool("bash", { cmd: "c" })]
expect(SessionProcessor.isDoomLoop(parts, "bash", { cmd: "c" })).toBe(false)
})

test("does not fire below the threshold of 3 tool calls", () => {
const parts = [reasoning(), tool("bash", { cmd: "ls" }), reasoning(), tool("bash", { cmd: "ls" })]
expect(SessionProcessor.isDoomLoop(parts, "bash", { cmd: "ls" })).toBe(false)
})

test("does not fire when a different tool breaks the streak", () => {
const parts = [tool("bash", { cmd: "ls" }), tool("read", { path: "x" }), tool("bash", { cmd: "ls" })]
expect(SessionProcessor.isDoomLoop(parts, "bash", { cmd: "ls" })).toBe(false)
})

test("ignores a pending tool call (not yet a confirmed repeat)", () => {
const parts = [tool("bash", { cmd: "ls" }), tool("bash", { cmd: "ls" }), tool("bash", { cmd: "ls" }, "pending")]
expect(SessionProcessor.isDoomLoop(parts, "bash", { cmd: "ls" })).toBe(false)
})
})
Loading