diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index b5289e903a1..54593c21954 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -20,6 +20,32 @@ export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) + const LOOP_WINDOW = 1024 + + const hashSnippet = (value: string) => { + let acc = 0 + for (let i = 0; i < value.length; i++) { + acc = (acc * 31 + value.charCodeAt(i)) >>> 0 + } + return acc + } + + const trimWindow = (value: string) => value.trim().slice(-LOOP_WINDOW) + + const snippetMatches = (value: string, snippet: string, signature: number) => { + const candidate = trimWindow(value) + return hashSnippet(candidate) === signature && candidate === snippet + } + + export function detectRepeatSnippet(values: string[], current: string, threshold = DOOM_LOOP_THRESHOLD) { + if (values.length < threshold) return + const snippet = trimWindow(current) + const signature = hashSnippet(snippet) + if (values.slice(-threshold).every((value) => snippetMatches(value, snippet, signature))) { + return snippet + } + } + export type Info = Awaited> export type Result = Awaited> @@ -35,6 +61,15 @@ export namespace SessionProcessor { let attempt = 0 let needsCompaction = false + const reasoningHistory: string[] = [] + const outputHistory: string[] = [] + const appendHistory = (history: string[], value: string) => { + history.push(value) + if (history.length > DOOM_LOOP_THRESHOLD * 2) { + history.shift() + } + } + const result = { get message() { return input.assistantMessage @@ -97,6 +132,24 @@ export namespace SessionProcessor { if (value.providerMetadata) part.metadata = value.providerMetadata await Session.updatePart(part) delete reasoningMap[value.id] + appendHistory(reasoningHistory, part.text) + const snippet = detectRepeatSnippet(reasoningHistory, part.text) + if (snippet) { + const agent = await Agent.get(input.assistantMessage.agent) + await PermissionNext.ask({ + permission: "doom_loop", + patterns: ["reasoning"], + sessionID: input.assistantMessage.sessionID, + metadata: { + reasoning: { + snippet, + }, + threshold: DOOM_LOOP_THRESHOLD, + }, + always: ["reasoning"], + ruleset: agent.permission, + }) + } } break @@ -321,6 +374,24 @@ export namespace SessionProcessor { } if (value.providerMetadata) currentText.metadata = value.providerMetadata await Session.updatePart(currentText) + appendHistory(outputHistory, currentText.text) + const snippet = detectRepeatSnippet(outputHistory, currentText.text) + if (snippet) { + const agent = await Agent.get(input.assistantMessage.agent) + await PermissionNext.ask({ + permission: "doom_loop", + patterns: ["output"], + sessionID: input.assistantMessage.sessionID, + metadata: { + output: { + snippet, + }, + threshold: DOOM_LOOP_THRESHOLD, + }, + always: ["output"], + ruleset: agent.permission, + }) + } } currentText = undefined break diff --git a/packages/opencode/test/session/processor.test.ts b/packages/opencode/test/session/processor.test.ts new file mode 100644 index 00000000000..07e99010eed --- /dev/null +++ b/packages/opencode/test/session/processor.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import { SessionProcessor } from "../../src/session/processor" + +describe("SessionProcessor.detectRepeatSnippet", () => { + test("requires at least threshold entries before triggering", () => { + const values = ["alpha", "alpha"] + expect(SessionProcessor.detectRepeatSnippet(values, "alpha")).toBeUndefined() + }) + + test("returns the trimmed snippet when the last threshold entries match", () => { + const values = ["foo", "foo", "foo"] + expect(SessionProcessor.detectRepeatSnippet(values, "foo")).toBe("foo") + }) + + test("returns undefined when the latest entries diverge", () => { + const values = ["foo", "foo", "bar", "foo"] + expect(SessionProcessor.detectRepeatSnippet(values, "foo")).toBeUndefined() + }) + + test("trims long outputs before comparing", () => { + const long = "x".repeat(2048) + const expected = long.slice(-1024) + const values = [long, long, long] + expect(SessionProcessor.detectRepeatSnippet(values, long)).toBe(expected) + }) +})