diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2b8aa9e03010..3bd47de713cd 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1191,6 +1191,47 @@ export namespace Config { .optional() .describe("Tools that should only be available to primary agents."), continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"), + thinking_loop: z + .object({ + enabled: z.boolean().optional().describe("Enable thinking loop detection and remediation"), + min_period: z.number().int().positive().optional().describe("Minimum repeated block size in chars"), + max_period: z.number().int().positive().optional().describe("Maximum repeated block size in chars"), + check_interval: z + .number() + .int() + .positive() + .optional() + .describe("How often to scan reasoning deltas, in chars"), + min_chars_before_detection: z + .number() + .int() + .positive() + .optional() + .describe("Minimum reasoning chars before loop detection starts"), + min_unique_chars: z + .number() + .int() + .positive() + .optional() + .describe("Minimum distinct characters required in a repeated block"), + max_nudges: z + .number() + .int() + .min(0) + .optional() + .describe("Number of reminder retries before escalating to compaction"), + max_compacts: z.number().int().min(0).optional().describe("Number of compaction retries before aborting"), + reminder_template: z + .string() + .optional() + .describe("Template for synthetic loop reminder, supports {period}"), + }) + .refine((data) => !data.min_period || !data.max_period || data.min_period <= data.max_period, { + message: "min_period must be less than or equal to max_period", + path: ["min_period"], + }) + .optional() + .describe("Thinking loop detection and automatic remediation settings"), mcp_timeout: z .number() .int() diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 1946eeee96a7..08b8b8698730 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -5,6 +5,7 @@ import { SessionID, MessageID, PartID } from "./schema" import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { MessageV2 } from "./message-v2" +import { isThinkingLoopOutcome } from "./thinking-loop" import z from "zod" import { Token } from "../util/token" import { Log } from "../util/log" @@ -232,6 +233,18 @@ When constructing the summary, try to stick to this template: return "stop" } + if (isThinkingLoopOutcome(result)) { + processor.message.error = new MessageV2.ThinkingLoopError({ + message: "Model got stuck in a repeated reasoning loop while compacting context", + period: result.period, + attempts: 1, + action: "abort", + }).toObject() + processor.message.finish = "error" + await Session.updateMessage(processor.message) + return "stop" + } + if (result === "continue" && input.auto) { if (replay) { const original = replay.info as MessageV2.User diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 90abf54526a7..76aaadbf8a3d 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -53,6 +53,15 @@ export namespace MessageV2 { "ContextOverflowError", z.object({ message: z.string(), responseBody: z.string().optional() }), ) + export const ThinkingLoopError = NamedError.create( + "ThinkingLoopError", + z.object({ + message: z.string(), + period: z.number(), + attempts: z.number(), + action: z.literal("abort"), + }), + ) export const OutputFormatText = z .object({ @@ -407,6 +416,7 @@ export namespace MessageV2 { AbortedError.Schema, StructuredOutputError.Schema, ContextOverflowError.Schema, + ThinkingLoopError.Schema, APIError.Schema, ]) .optional(), diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 38dac41b0589..2251f31807a1 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -14,8 +14,8 @@ import { Config } from "@/config/config" import { SessionCompaction } from "./compaction" import { PermissionNext } from "@/permission/next" import { Question } from "@/question" -import { PartID } from "./schema" -import type { SessionID, MessageID } from "./schema" +import { ThinkingLoopDetector, type ThinkingLoopOutcome } from "./thinking-loop" +import { SessionID, PartID } from "./schema" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -46,11 +46,44 @@ export namespace SessionProcessor { async process(streamInput: LLM.StreamInput) { log.info("process") needsCompaction = false - const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true + const experimental = (await Config.get()).experimental + const shouldBreak = experimental?.continue_loop_on_deny !== true + const thinking = experimental?.thinking_loop + const thinkingEnabled = thinking?.enabled !== false while (true) { try { let currentText: MessageV2.TextPart | undefined let reasoningMap: Record = {} + let thinkingLoop: ThinkingLoopOutcome | undefined + const detector = thinkingEnabled + ? new ThinkingLoopDetector({ + min_period: thinking?.min_period, + max_period: thinking?.max_period, + check_interval: thinking?.check_interval, + min_chars_before_detection: thinking?.min_chars_before_detection, + min_unique_chars: thinking?.min_unique_chars, + on_loop_detected(info) { + log.warn("thinking loop detected", { + sessionID: input.sessionID, + period: info.period, + sample: info.sample, + }) + }, + }) + : undefined + + const closeReasoning = async () => { + const now = Date.now() + for (const part of Object.values(reasoningMap)) { + part.text = part.text.trimEnd() + part.time = { + ...part.time, + end: now, + } + await Session.updatePart(part) + } + reasoningMap = {} + } const stream = await LLM.stream(streamInput) for await (const value of stream.fullStream) { @@ -91,6 +124,12 @@ export namespace SessionProcessor { field: "text", delta: value.text, }) + const outcome = detector?.feed(value.text) + if (outcome) { + thinkingLoop = outcome + await closeReasoning() + break + } } break @@ -349,7 +388,10 @@ export namespace SessionProcessor { }) continue } - if (needsCompaction) break + if (needsCompaction || thinkingLoop) break + } + if (thinkingLoop) { + return thinkingLoop } } catch (e: any) { log.error("process", { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b8be93b6be00..51ed03652bbb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -34,6 +34,7 @@ import { Command } from "../command" import { $ } from "bun" import { pathToFileURL, fileURLToPath } from "url" import { ConfigMarkdown } from "../config/markdown" +import { Config } from "../config/config" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" import { fn } from "@/util/fn" @@ -46,6 +47,7 @@ import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncation" +import { getRecoveryAction, isThinkingLoopOutcome } from "./thinking-loop" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -289,6 +291,7 @@ export namespace SessionPrompt { // Note: On session resumption, state is reset but outputFormat is preserved // on the user message and will be retrieved from lastUser below let structuredOutput: unknown | undefined + let thinkingLoopAttempt = 0 let step = 0 const session = await Session.get(sessionID) @@ -707,6 +710,48 @@ export namespace SessionPrompt { } } + if (isThinkingLoopOutcome(result)) { + const thinking = (await Config.get()).experimental?.thinking_loop + const action = getRecoveryAction(thinkingLoopAttempt, result.period, { + max_nudges: thinking?.max_nudges, + max_compacts: thinking?.max_compacts, + reminder_template: thinking?.reminder_template, + }) + thinkingLoopAttempt++ + + if (action.type === "nudge") { + await enqueueThinkingLoopReminder({ + user: lastUser, + sessionID, + reminder: action.reminder, + }) + continue + } + + if (action.type === "compact") { + await SessionCompaction.create({ + sessionID, + agent: lastUser.agent, + model: lastUser.model, + auto: true, + overflow: true, + }) + continue + } + + processor.message.error = new MessageV2.ThinkingLoopError({ + message: "Model got stuck in a repeated reasoning loop and automatic remediation exhausted", + period: action.period, + attempts: action.attempts, + action: "abort", + }).toObject() + processor.message.finish = processor.message.finish ?? "error" + await Session.updateMessage(processor.message) + break + } + + thinkingLoopAttempt = 0 + if (result === "stop") break if (result === "compact") { await SessionCompaction.create({ @@ -738,6 +783,33 @@ export namespace SessionPrompt { return Provider.defaultModel() } + async function enqueueThinkingLoopReminder(input: { sessionID: SessionID; user: MessageV2.User; reminder: string }) { + const message = await Session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: input.sessionID, + time: { created: Date.now() }, + agent: input.user.agent, + model: input.user.model, + format: input.user.format, + tools: input.user.tools, + system: input.user.system, + variant: input.user.variant, + }) + await Session.updatePart({ + id: PartID.ascending(), + messageID: message.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: ["", input.reminder, ""].join("\n"), + time: { + start: Date.now(), + end: Date.now(), + }, + }) + } + /** @internal Exported for testing */ export async function resolveTools(input: { agent: Agent.Info diff --git a/packages/opencode/src/session/thinking-loop.ts b/packages/opencode/src/session/thinking-loop.ts new file mode 100644 index 000000000000..bfb543ceefa3 --- /dev/null +++ b/packages/opencode/src/session/thinking-loop.ts @@ -0,0 +1,212 @@ +import { Log } from "@/util/log" + +const log = Log.create({ service: "session.thinking-loop" }) + +const DETECTOR_DEFAULTS = { + min_period: 20, + max_period: 1000, + check_interval: 100, + raw_buffer_size: 4000, + min_chars_before_detection: 500, + min_unique_chars: 10, +} as const + +const RECOVERY_DEFAULTS = { + max_nudges: 1, + max_compacts: 1, +} as const + +const DEFAULT_REMINDER = + "Your reasoning output is repeating in a loop (~{period} chars). Stop repeating and immediately do the next concrete step: make a tool call or provide your final answer." + +export type ThinkingLoopOutcome = { + type: "thinking_loop" + period: number +} + +export type ThinkingLoopConfig = { + enabled?: boolean + min_period?: number + max_period?: number + check_interval?: number + min_chars_before_detection?: number + min_unique_chars?: number + max_nudges?: number + max_compacts?: number + reminder_template?: string +} + +export type ThinkingLoopDetectorOptions = { + min_period?: number + max_period?: number + check_interval?: number + raw_buffer_size?: number + min_chars_before_detection?: number + min_unique_chars?: number + on_loop_detected?: (info: { period: number; sample: string }) => void +} + +export type RecoveryOptions = { + max_nudges?: number + max_compacts?: number + reminder_template?: string +} + +export type RecoveryAction = + | { type: "nudge"; reminder: string } + | { type: "compact" } + | { type: "abort"; period: number; attempts: number } + +function normalizeWhitespace(input: string) { + return input.replace(/\s+/g, " ").trim() +} + +function uniqueChars(input: string) { + return new Set(input).size +} + +function hasAlphaNumeric(input: string) { + return /[A-Za-z0-9]/.test(input) +} + +export function isThinkingLoopOutcome(input: unknown): input is ThinkingLoopOutcome { + return ( + typeof input === "object" && + input !== null && + Object.hasOwn(input, "type") && + (input as ThinkingLoopOutcome).type === "thinking_loop" + ) +} + +export class ThinkingLoopDetector { + private raw = "" + private total = 0 + private since = 0 + private detected = false + private period = 0 + private min_period: number + private max_period: number + private check_interval: number + private raw_buffer_size: number + private min_chars_before_detection: number + private min_unique_chars: number + private on_loop_detected?: ThinkingLoopDetectorOptions["on_loop_detected"] + + constructor(options: ThinkingLoopDetectorOptions = {}) { + this.min_period = options.min_period ?? DETECTOR_DEFAULTS.min_period + this.max_period = options.max_period ?? DETECTOR_DEFAULTS.max_period + if (this.min_period > this.max_period) { + throw new Error(`min_period (${this.min_period}) must be <= max_period (${this.max_period})`) + } + this.check_interval = options.check_interval ?? DETECTOR_DEFAULTS.check_interval + this.min_chars_before_detection = options.min_chars_before_detection ?? DETECTOR_DEFAULTS.min_chars_before_detection + this.min_unique_chars = options.min_unique_chars ?? DETECTOR_DEFAULTS.min_unique_chars + const minBufferSize = this.max_period * 3 + 1 + if (options.raw_buffer_size && options.raw_buffer_size < minBufferSize) { + log.warn("raw_buffer_size too small, using minimum", { + raw_buffer_size: options.raw_buffer_size, + max_period: this.max_period, + minimum: minBufferSize, + }) + } + this.raw_buffer_size = Math.max(options.raw_buffer_size ?? DETECTOR_DEFAULTS.raw_buffer_size, minBufferSize) + this.on_loop_detected = options.on_loop_detected + } + + feed(delta: string): ThinkingLoopOutcome | undefined { + if (this.detected) return { type: "thinking_loop", period: this.period } + + this.total += delta.length + this.since += delta.length + this.raw += delta + + if (this.raw.length > this.raw_buffer_size) { + this.raw = this.raw.slice(-this.raw_buffer_size) + } + + if (this.total < this.min_chars_before_detection) return + if (this.since < this.check_interval) return + this.since = 0 + + return this.checkForLoop() + } + + private checkForLoop(): ThinkingLoopOutcome | undefined { + const bufferLength = this.raw.length + // To detect a period of p, we need at least 3*p characters (for 3 consecutive occurrences) + const maxDetectablePeriod = Math.min(this.max_period, Math.floor(bufferLength / 3)) + + // Check each possible period from largest to smallest (prefer detecting longer periods) + for (let period = maxDetectablePeriod; period >= this.min_period; period--) { + if (this.hasRepeatingPattern(period)) { + this.detected = true + this.period = period + const sample = this.getNormalizedSample(period) + this.on_loop_detected?.({ period, sample }) + return { type: "thinking_loop", period } + } + } + } + + // Fast path: check end characters before doing full string comparison + // If the pattern repeats, the last char should equal the char at -period and -2*period + private hasRepeatingPattern(period: number): boolean { + const end = this.raw.length - 1 + if (this.raw[end] !== this.raw[end - period]) return false + if (this.raw[end] !== this.raw[end - period * 2]) return false + if (this.raw[end - period + 1] !== this.raw[end - period * 2 + 1]) return false + + return this.matchesNormalizedPattern(period) + } + + // Compare 3 consecutive segments of length 'period' after whitespace normalization + private matchesNormalizedPattern(period: number): boolean { + const end = this.raw.length + const first = normalizeWhitespace(this.raw.slice(end - period * 3, end - period * 2)) + const second = normalizeWhitespace(this.raw.slice(end - period * 2, end - period)) + const third = normalizeWhitespace(this.raw.slice(end - period, end)) + + if (!first || first !== second || second !== third) return false + if (!hasAlphaNumeric(third)) return false + if (uniqueChars(third) < this.min_unique_chars) return false + + return true + } + + private getNormalizedSample(period: number): string { + const end = this.raw.length + const sample = this.raw.slice(end - period, end) + return normalizeWhitespace(sample).slice(0, 100) + } + + reset() { + this.raw = "" + this.total = 0 + this.since = 0 + this.detected = false + this.period = 0 + } +} + +export function getRecoveryAction(attempt: number, period: number, options: RecoveryOptions = {}): RecoveryAction { + const nudges = options.max_nudges ?? RECOVERY_DEFAULTS.max_nudges + const compacts = options.max_compacts ?? RECOVERY_DEFAULTS.max_compacts + const template = options.reminder_template ?? DEFAULT_REMINDER + + if (attempt < nudges) { + return { + type: "nudge", + reminder: template.replaceAll("{period}", `${period}`), + } + } + + if (attempt < nudges + compacts) { + return { type: "compact" } + } + + return { + type: "abort", + period, + attempts: attempt + 1, + } +} diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 1ebd273d2664..aeb6b4ade164 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -50,6 +50,10 @@ const cacheDir = path.join(dir, "cache", "opencode") await fs.mkdir(cacheDir, { recursive: true }) await fs.writeFile(path.join(cacheDir, "version"), "14") +// Clear config overrides to prevent user environment from leaking into tests +delete process.env["OPENCODE_CONFIG"] +delete process.env["OPENCODE_CONFIG_DIR"] + // Clear provider and server auth env vars to ensure clean test state delete process.env["ANTHROPIC_API_KEY"] delete process.env["OPENAI_API_KEY"] diff --git a/packages/opencode/test/session/thinking-loop.test.ts b/packages/opencode/test/session/thinking-loop.test.ts new file mode 100644 index 000000000000..c177bdaaa131 --- /dev/null +++ b/packages/opencode/test/session/thinking-loop.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import { ThinkingLoopDetector, getRecoveryAction } from "../../src/session/thinking-loop" + +describe("session.thinking-loop.detector", () => { + test("detects repeated reasoning block", () => { + const detector = new ThinkingLoopDetector({ + min_period: 20, + max_period: 200, + check_interval: 1, + min_chars_before_detection: 1, + min_unique_chars: 5, + }) + const chunk = "Looking at the code, we should call the tool now. " + const input = chunk.repeat(3) + const result = detector.feed(input) + expect(result?.type).toBe("thinking_loop") + expect(result?.period).toBeGreaterThanOrEqual(20) + }) + + test("does not detect for low entropy repeated punctuation", () => { + const detector = new ThinkingLoopDetector({ + min_period: 10, + max_period: 100, + check_interval: 1, + min_chars_before_detection: 1, + min_unique_chars: 5, + }) + const result = detector.feed("!!!\n!!!\n!!!\n!!!\n!!!\n!!!\n") + expect(result).toBeUndefined() + }) + + test("does not detect when repeated block has no alphanumeric chars", () => { + const detector = new ThinkingLoopDetector({ + min_period: 6, + max_period: 40, + check_interval: 1, + min_chars_before_detection: 1, + min_unique_chars: 2, + }) + const block = "--- *** --- " + const result = detector.feed(block.repeat(3)) + expect(result).toBeUndefined() + }) +}) + +describe("session.thinking-loop.recovery", () => { + test("escalates nudge -> compact -> abort", () => { + const first = getRecoveryAction(0, 120, { + max_nudges: 1, + max_compacts: 1, + }) + const second = getRecoveryAction(1, 120, { + max_nudges: 1, + max_compacts: 1, + }) + const third = getRecoveryAction(2, 120, { + max_nudges: 1, + max_compacts: 1, + }) + + expect(first.type).toBe("nudge") + expect(second.type).toBe("compact") + expect(third.type).toBe("abort") + if (third.type === "abort") { + expect(third.period).toBe(120) + expect(third.attempts).toBe(3) + } + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 30568c96df12..96964c205f86 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -185,6 +185,16 @@ export type ContextOverflowError = { } } +export type ThinkingLoopError = { + name: "ThinkingLoopError" + data: { + message: string + period: number + attempts: number + action: "abort" + } +} + export type ApiError = { name: "APIError" data: { @@ -216,6 +226,7 @@ export type AssistantMessage = { | MessageAbortedError | StructuredOutputError | ContextOverflowError + | ThinkingLoopError | ApiError parentID: string modelID: string @@ -878,6 +889,7 @@ export type EventSessionError = { | MessageAbortedError | StructuredOutputError | ContextOverflowError + | ThinkingLoopError | ApiError } } @@ -1496,6 +1508,47 @@ export type Config = { * Continue the agent loop when a tool call is denied */ continue_loop_on_deny?: boolean + /** + * Thinking loop detection and automatic remediation settings + */ + thinking_loop?: { + /** + * Enable thinking loop detection and remediation + */ + enabled?: boolean + /** + * Minimum repeated block size in chars + */ + min_period?: number + /** + * Maximum repeated block size in chars + */ + max_period?: number + /** + * How often to scan reasoning deltas, in chars + */ + check_interval?: number + /** + * Minimum reasoning chars before loop detection starts + */ + min_chars_before_detection?: number + /** + * Minimum distinct characters required in a repeated block + */ + min_unique_chars?: number + /** + * Number of reminder retries before escalating to compaction + */ + max_nudges?: number + /** + * Number of compaction retries before aborting + */ + max_compacts?: number + /** + * Template for synthetic loop reminder, supports {period} + */ + reminder_template?: string + } /** * Timeout in milliseconds for model context protocol (MCP) requests */