Skip to content
Closed
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
36 changes: 36 additions & 0 deletions packages/opencode/src/session/doom-loop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export const MAX_PERIOD = 10

export interface Detector {
readonly check: (tool: string, input: Record<string, unknown>) => boolean
}

export function create(): Detector {
const capacity = 2 * MAX_PERIOD + 1
const signatures = Array.from({ length: capacity }, () => "")
const streaks = Array.from({ length: MAX_PERIOD + 1 }, () => 0)
let next = 0
let size = 0

function latest(offset: number) {
return signatures[(next - 1 - offset + capacity) % capacity]
}

return {
check(tool, input) {
signatures[next] = JSON.stringify([tool, input])
next = (next + 1) % capacity
size = Math.min(size + 1, capacity)

let detected = false
for (let period = 1; period <= MAX_PERIOD; period++) {
const same = size > 2 * period && latest(0) === latest(period) && latest(0) === latest(2 * period)
// A streak of p matching triples proves three equal length-p suffix blocks.
streaks[period] = same ? Math.min(streaks[period] + 1, period) : 0
if (streaks[period] === period) detected = true
}
return detected
},
}
}

export * as DoomLoop from "./doom-loop"
34 changes: 12 additions & 22 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Snapshot } from "@/snapshot"
import { Session } from "./session"
import { LLM } from "./llm"
import { MessageV2 } from "./message-v2"
import { DoomLoop } from "./doom-loop"
import { isOverflow } from "./overflow"
import { PartID } from "./schema"
import type { SessionID } from "./schema"
Expand All @@ -23,10 +24,8 @@ import { Question } from "@/question"
import { errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Database } from "@opencode-ai/core/database/database"
import { Usage, type LLMEvent } from "@opencode-ai/llm"

const DOOM_LOOP_THRESHOLD = 3
export type Result = "compact" | "stop" | "continue"

export interface Handle {
Expand Down Expand Up @@ -62,10 +61,13 @@ type ToolCall = {
messageID: SessionV1.ToolPart["messageID"]
sessionID: SessionV1.ToolPart["sessionID"]
done: Deferred.Deferred<void>
// Overlapping updates replace the record, but must share the same admission state.
readonly admission: { counted: boolean }
}

interface ProcessorContext extends Input {
toolcalls: Record<string, ToolCall>
doomLoop: DoomLoop.Detector
shouldBreak: boolean
snapshot: string | undefined
blocked: boolean
Expand Down Expand Up @@ -93,7 +95,6 @@ const layer = Layer.effect(
const status = yield* SessionStatus.Service
const image = yield* Image.Service
const events = yield* EventV2Bridge.Service
const database = yield* Database.Service

const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
// Pre-capture snapshot before the LLM stream starts. The AI SDK
Expand All @@ -105,6 +106,7 @@ const layer = Layer.effect(
sessionID: input.sessionID,
model: input.model,
toolcalls: {},
doomLoop: DoomLoop.create(),
shouldBreak: false,
snapshot: initialSnapshot,
blocked: false,
Expand Down Expand Up @@ -245,6 +247,7 @@ const layer = Layer.effect(
} satisfies SessionV1.ToolPart)
ctx.toolcalls[input.id] = {
done: yield* Deferred.make<void>(),
admission: { counted: false },
partID: part.id,
messageID: part.messageID,
sessionID: part.sessionID,
Expand Down Expand Up @@ -334,7 +337,7 @@ const layer = Layer.effect(
}
yield* ensureToolCall(value)
const input = isRecord(value.input) ? value.input : { value: value.input }
yield* updateToolCall(value.id, (match) => ({
const part = yield* updateToolCall(value.id, (match) => ({
...match,
tool: value.name,
state:
Expand All @@ -350,23 +353,11 @@ const layer = Layer.effect(
: value.providerMetadata,
}))

const parts = yield* MessageV2.parts(ctx.assistantMessage.id).pipe(
Effect.provideService(Database.Service, database),
)
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)

if (
recentParts.length !== DOOM_LOOP_THRESHOLD ||
!recentParts.every(
(part) =>
part.type === "tool" &&
part.tool === value.name &&
part.state.status !== "pending" &&
JSON.stringify(part.state.input) === JSON.stringify(input),
)
) {
return
}
// Metadata may start execution before normalized delivery; persisted status is not an admission flag.
const call = ctx.toolcalls[value.id]
if (!part || !call || call.admission.counted) return
call.admission.counted = true
if (!ctx.doomLoop.check(value.name, input)) return

const agent = yield* agents.get(ctx.assistantMessage.agent)
yield* permission.ask({
Expand Down Expand Up @@ -725,7 +716,6 @@ export const node = LayerNode.make({
SessionStatus.node,
Image.node,
EventV2Bridge.node,
Database.node,
],
})

Expand Down
139 changes: 139 additions & 0 deletions packages/opencode/test/session/doom-loop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, expect, test } from "bun:test"
import { DoomLoop } from "../../src/session/doom-loop"

type Call = { tool: string; input: Record<string, unknown> }

function pattern(period: number): Call[] {
return Array.from({ length: period }, (_, index) => ({ tool: "lookup", input: { query: index } }))
}

function repeat(calls: readonly Call[], count: number) {
return Array.from({ length: count }, () => calls).flat()
}

function results(calls: readonly Call[]) {
const detector = DoomLoop.create()
return calls.map((call) => detector.check(call.tool, call.input))
}

function oracle(calls: readonly Call[]) {
const signatures = calls.map((call) => JSON.stringify([call.tool, call.input]))
for (let period = 1; period <= DoomLoop.MAX_PERIOD; period++) {
if (signatures.length < 3 * period) continue
const blocks = [3, 2, 1].map((offset) =>
signatures.slice(signatures.length - offset * period, signatures.length - (offset - 1) * period),
)
if (
JSON.stringify(blocks[0]) === JSON.stringify(blocks[1]) &&
JSON.stringify(blocks[1]) === JSON.stringify(blocks[2])
) {
return true
}
}
return false
}

describe("doom-loop detector", () => {
test.each(Array.from({ length: DoomLoop.MAX_PERIOD }, (_, index) => index + 1))(
"detects period %i only when the third block completes",
(period) => {
const detected = results(repeat(pattern(period), 3))
expect(detected.slice(0, -1).every((value) => !value)).toBe(true)
expect(detected.at(-1)).toBe(true)
},
)

test("does not detect only two repetitions", () => {
expect(results(repeat(pattern(DoomLoop.MAX_PERIOD), 2)).every((value) => !value)).toBe(true)
})

test("does not detect a fundamental period above the bound", () => {
expect(results(repeat(pattern(DoomLoop.MAX_PERIOD + 1), 4)).every((value) => !value)).toBe(true)
})

test("does not detect when the third block changes an input", () => {
const calls = repeat(pattern(3), 3)
calls[8] = { tool: "lookup", input: { query: "changed" } }
expect(results(calls).every((value) => !value)).toBe(true)
})

test("does not detect when the third block changes a tool name", () => {
const calls = repeat(pattern(3), 3)
calls[8] = { ...calls[8], tool: "search" }
expect(results(calls).every((value) => !value)).toBe(true)
})

test("keeps detecting a continuing cycle", () => {
const detected = results(repeat(pattern(3), 5))
expect(detected.slice(0, 8).every((value) => !value)).toBe(true)
expect(detected.slice(8).every(Boolean)).toBe(true)
})

test("detects the smaller fundamental period", () => {
expect(results(repeat(repeat(pattern(2), 2), 3)).findIndex(Boolean)).toBe(5)
})

test("preserves JSON.stringify key-order semantics", () => {
expect(
results([
{ tool: "lookup", input: { a: 1, b: 2 } },
{ tool: "lookup", input: { b: 2, a: 1 } },
{ tool: "lookup", input: { a: 1, b: 2 } },
]),
).toEqual([false, false, false])
})

test("does not retain mutable input objects", () => {
const detector = DoomLoop.create()
const input = { query: "a" }
expect(detector.check("lookup", input)).toBe(false)
input.query = "b"
expect(detector.check("lookup", input)).toBe(false)
expect(detector.check("lookup", input)).toBe(false)
expect(detector.check("lookup", input)).toBe(true)
})

test("keeps separate detector instances isolated", () => {
const first = DoomLoop.create()
const second = DoomLoop.create()
expect(first.check("lookup", {})).toBe(false)
expect(first.check("lookup", {})).toBe(false)
expect(second.check("lookup", {})).toBe(false)
expect(first.check("lookup", {})).toBe(true)
})

test("detects a maximum-period suffix after many ring wraps", () => {
const noise = Array.from({ length: 10_000 }, (_, index) => ({ tool: "noise", input: { index } }))
const detected = results([...noise, ...repeat(pattern(DoomLoop.MAX_PERIOD), 3)])
expect(detected.slice(0, -1).every((value) => !value)).toBe(true)
expect(detected.at(-1)).toBe(true)
})

test("matches an independent suffix-block oracle for every short binary sequence", () => {
const alphabet = pattern(2)
for (let length = 1; length <= 10; length++) {
for (let mask = 0; mask < 1 << length; mask++) {
const detector = DoomLoop.create()
const calls: Call[] = []
for (let index = 0; index < length; index++) {
const call = alphabet[(mask >> index) & 1]
calls.push(call)
expect(detector.check(call.tool, call.input)).toBe(oracle(calls))
}
}
}
})

test("matches the oracle across ring wraps, near misses, and changing periods", () => {
const calls = Array.from({ length: DoomLoop.MAX_PERIOD + 1 }, (_, index) => [
...repeat(pattern(index + 1), 4),
{ tool: "noise", input: { index } },
...repeat(pattern(index + 1), 2),
{ tool: "changed", input: { index } },
]).flat()
const detector = DoomLoop.create()
calls.forEach((call, index) => {
expect(detector.check(call.tool, call.input)).toBe(oracle(calls.slice(0, index + 1)))
})
})
})
Loading
Loading