Skip to content
Open
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
71 changes: 71 additions & 0 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof create>>
export type Result = Awaited<ReturnType<Info["process"]>>

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions packages/opencode/test/session/processor.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading