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
46 changes: 46 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,52 @@ export namespace Config {
.positive()
.optional()
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
loop: z
.object({
enabled: z.boolean().optional().describe("Enable loop detection for reasoning and text streams"),
min_period: z
.number()
.int()
.positive()
.optional()
.describe("Minimum repeating pattern length in characters"),
max_period: z
.number()
.int()
.positive()
.optional()
.describe("Maximum repeating pattern length in characters"),
similarity: z
.number()
.min(0)
.max(1)
.optional()
.describe("Similarity threshold for near-identical repetition detection (0-1)"),
check_interval: z
.number()
.int()
.positive()
.optional()
.describe("Number of characters between detection checks"),
min_chars: z
.number()
.int()
.positive()
.optional()
.describe("Minimum characters received before detection starts"),
max_nudges: z
.number()
.int()
.min(0)
.optional()
.describe("Maximum nudge attempts before aborting on loop detection"),
reminder: z.string().optional().describe("Custom reminder message injected on nudge"),
})
.refine((d) => !d.min_period || !d.max_period || d.min_period <= d.max_period, {
message: "min_period must be <= max_period",
})
.optional()
.describe("Loop detection configuration for reasoning and text streams"),
})
.optional(),
})
Expand Down
14 changes: 14 additions & 0 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import z from "zod"
import { Token } from "../util/token"
import { Log } from "../util/log"
import { SessionProcessor } from "./processor"
import { isLoopOutcome } from "./loop"
import { fn } from "@/util/fn"
import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin"
Expand Down Expand Up @@ -271,6 +272,19 @@ When constructing the summary, try to stick to this template:
})
.pipe(Effect.onInterrupt(() => processor.abort()))

if (isLoopOutcome(result)) {
processor.message.error = new MessageV2.LoopError({
message: `Repetitive ${result.source} output detected during compaction (period ~${result.period} chars)`,
period: result.period,
attempts: 1,
action: "abort",
source: result.source,
}).toObject()
processor.message.finish = "error"
yield* session.updateMessage(processor.message)
return "stop"
}

if (result === "compact") {
processor.message.error = new MessageV2.ContextOverflowError({
message: replay
Expand Down
155 changes: 155 additions & 0 deletions packages/opencode/src/session/loop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
export type LoopOutcome = {
type: "loop"
period: number
source: "reasoning" | "text"
}

export const DEFAULTS = {
min_period: 10,
max_period: 2000,
similarity: 1.0,
check_interval: 100,
min_chars: 200,
max_nudges: 1,
} as const

const REMINDER =
"<system-reminder>\nYour output is repeating in a loop with period ~{period} characters. Stop repeating and take a different, concrete action.\n</system-reminder>"

// Unicode letter-or-digit check: segments consisting entirely of punctuation,
// whitespace, or symbols are filtered as false positives. This handles non-Latin
// text (CJK, Arabic, etc.) while rejecting structural patterns like "---" or "| --- |".
const ALPHANUMERIC = /[\p{L}\p{N}]/u

function hasAlphanumeric(text: string) {
return ALPHANUMERIC.test(text)
}

// Collapse runs of whitespace to a single space and trim. LLM outputs vary in
// whitespace between otherwise identical repetitions (extra newlines, trailing
// spaces, indentation drift), so normalization prevents missed detections.
function normalize(text: string) {
return text.replace(/\s+/g, " ").trim()
}

function similarity(first: string, second: string, threshold: number) {
const length = Math.max(first.length, second.length)
if (length === 0) return 1.0

// If lengths differ by more than the tolerance budget, reject early
if (Math.abs(first.length - second.length) > (1 - threshold) * length) return 0

let matches = 0
const shorter = Math.min(first.length, second.length)

for (let i = 0; i < shorter; i++) {
if (first[i] === second[i]) matches++
}

return matches / length
}

export function isLoopOutcome(value: unknown): value is LoopOutcome {
return typeof value === "object" && value !== null && (value as LoopOutcome).type === "loop"
}

export function recovery(
attempt: number,
options?: { max_nudges?: number; reminder?: string; period?: number },
): { action: "nudge"; reminder: string } | { action: "abort"; period: number; attempts: number } {
const nudges = options?.max_nudges ?? DEFAULTS.max_nudges
const period = options?.period ?? 0

if (attempt < nudges) {
const template = options?.reminder ?? REMINDER
return { action: "nudge", reminder: template.replace("{period}", String(period)) }
}

return { action: "abort", period, attempts: attempt + 1 }
}

export function create(options: {
source: "reasoning" | "text"
min_period?: number
max_period?: number
similarity?: number
check_interval?: number
min_chars?: number
on_detected?: (outcome: LoopOutcome) => void
}) {
const minPeriod = options.min_period ?? DEFAULTS.min_period
const maxPeriod = options.max_period ?? DEFAULTS.max_period
const threshold = options.similarity ?? DEFAULTS.similarity
const interval = options.check_interval ?? DEFAULTS.check_interval
const minChars = options.min_chars ?? DEFAULTS.min_chars
const capacity = 2 * maxPeriod
const source = options.source

let buffer = ""
let total = 0
let last = 0

function detect(): LoopOutcome | undefined {
const length = buffer.length
if (length < 2 * minPeriod) return undefined

// Scan from longest candidate period down to shortest. Longer periods are
// checked first so we report the most meaningful repeating unit.
const upper = Math.min(Math.floor(length / 2), maxPeriod)
const lower = minPeriod

for (let period = upper; period >= lower; period--) {
// Two-position spot-check fast path: compare the last character of the
// buffer against the character one period earlier, and the midpoint of
// the second segment against the midpoint of the first. Two independent
// checks at different offsets give a false-pass probability of roughly
// 1/(alphabet_size^2), rejecting ~99.95% of non-repeating periods in O(1).
const tail = length - 1
const mid = length - 1 - Math.floor(period / 2)
if (buffer[tail] !== buffer[tail - period]) continue
if (buffer[mid] !== buffer[mid - period]) continue

// Full normalized comparison: extract two adjacent segments of length period,
// normalize whitespace, then compare.
const first = normalize(buffer.slice(length - 2 * period, length - period))
const second = normalize(buffer.slice(length - period))

const score = threshold >= 1.0 ? (first === second ? 1.0 : 0) : similarity(first, second, threshold)
if (score < threshold) continue

// Alphanumeric false-positive filter: reject segments that contain no
// Unicode letters or digits. This filters structural patterns like
// markdown separators ("---"), bullet markers, and ASCII art without
// needing brittle pattern-specific rules.
if (!ALPHANUMERIC.test(second)) continue

const outcome: LoopOutcome = { type: "loop", period, source }
options.on_detected?.(outcome)
return outcome
}

return undefined
}

return {
feed(delta: string): LoopOutcome | undefined {
buffer += delta
total += delta.length

// Keep buffer bounded to 2 * max_period
if (buffer.length > capacity) buffer = buffer.slice(buffer.length - capacity)

if (total < minChars) return undefined
if (total - last < interval) return undefined

last = total
return detect()
},

reset() {
buffer = ""
total = 0
last = 0
},
}
}
12 changes: 12 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ export namespace MessageV2 {
z.object({ message: z.string(), responseBody: z.string().optional() }),
)

export const LoopError = NamedError.create(
"LoopError",
z.object({
message: z.string(),
period: z.number(),
attempts: z.number(),
action: z.literal("abort"),
source: z.enum(["reasoning", "text"]),
}),
)

export const OutputFormatText = z
.object({
type: z.literal("text"),
Expand Down Expand Up @@ -414,6 +425,7 @@ export namespace MessageV2 {
AbortedError.Schema,
StructuredOutputError.Schema,
ContextOverflowError.Schema,
LoopError.Schema,
APIError.Schema,
])
.optional(),
Expand Down
36 changes: 33 additions & 3 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Snapshot } from "@/snapshot"
import { Log } from "@/util/log"
import { Session } from "."
import { LLM } from "./llm"
import { create as createLoop, type LoopOutcome } from "./loop"
import { MessageV2 } from "./message-v2"
import { isOverflow } from "./overflow"
import { PartID } from "./schema"
Expand All @@ -23,7 +24,7 @@ export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
const log = Log.create({ service: "session.processor" })

export type Result = "compact" | "stop" | "continue"
export type Result = "compact" | "stop" | "continue" | LoopOutcome

export type Event = LLM.Event

Expand Down Expand Up @@ -52,6 +53,7 @@ export namespace SessionProcessor {
needsCompaction: boolean
currentText: MessageV2.TextPart | undefined
reasoningMap: Record<string, MessageV2.ReasoningPart>
loopOutcome: LoopOutcome | undefined
}

type StreamEvent = Event
Expand Down Expand Up @@ -99,6 +101,7 @@ export namespace SessionProcessor {
needsCompaction: false,
currentText: undefined,
reasoningMap: {},
loopOutcome: undefined,
}
let aborted = false

Expand Down Expand Up @@ -445,7 +448,13 @@ export namespace SessionProcessor {
const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
log.info("process")
ctx.needsCompaction = false
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
ctx.loopOutcome = undefined
const cfg = yield* config.get()
ctx.shouldBreak = cfg.experimental?.continue_loop_on_deny !== true

const loop = cfg.experimental?.loop
const reasoning = loop?.enabled !== false ? createLoop({ source: "reasoning", ...loop }) : undefined
const text = loop?.enabled !== false ? createLoop({ source: "text", ...loop }) : undefined

return yield* Effect.gen(function* () {
yield* Effect.gen(function* () {
Expand All @@ -455,7 +464,27 @@ export namespace SessionProcessor {

yield* stream.pipe(
Stream.tap((event) => handleEvent(event)),
Stream.takeUntil(() => ctx.needsCompaction),
Stream.tap((event) =>
Effect.sync(() => {
if (event.type === "reasoning-start") reasoning?.reset()
if (event.type === "text-start") text?.reset()
if (event.type === "reasoning-delta") {
const outcome = reasoning?.feed(event.text)
if (outcome) {
ctx.loopOutcome = outcome
log.warn("loop", { sessionID: ctx.sessionID, period: outcome.period, source: outcome.source })
}
}
if (event.type === "text-delta") {
const outcome = text?.feed(event.text)
if (outcome) {
ctx.loopOutcome = outcome
log.warn("loop", { sessionID: ctx.sessionID, period: outcome.period, source: outcome.source })
}
}
}),
),
Stream.takeUntil(() => ctx.needsCompaction || !!ctx.loopOutcome),
Stream.runDrain,
)
}).pipe(
Expand Down Expand Up @@ -483,6 +512,7 @@ export namespace SessionProcessor {
if (aborted && !ctx.assistantMessage.error) {
yield* abort()
}
if (ctx.loopOutcome) return ctx.loopOutcome
if (ctx.needsCompaction) return "compact"
if (ctx.blocked || ctx.assistantMessage.error || aborted) return "stop"
return "continue"
Expand Down
Loading
Loading