From 4445701611608af8e47b5b93de369a74615b613e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kub=C3=ADk?= Date: Sun, 5 Apr 2026 16:47:16 +0200 Subject: [PATCH] feat: implement loop detection for thinking and text --- packages/opencode/src/config/config.ts | 46 +++ packages/opencode/src/session/compaction.ts | 14 + packages/opencode/src/session/loop.ts | 155 ++++++++ packages/opencode/src/session/message-v2.ts | 12 + packages/opencode/src/session/processor.ts | 36 +- packages/opencode/src/session/prompt.ts | 52 +++ .../test/session/loop-integration.test.ts | 254 ++++++++++++ packages/opencode/test/session/loop.test.ts | 364 ++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 50 +++ 9 files changed, 980 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/src/session/loop.ts create mode 100644 packages/opencode/test/session/loop-integration.test.ts create mode 100644 packages/opencode/test/session/loop.test.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index efae2ca55168..c26a78b20eb4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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(), }) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index bbdce9fd7472..1170e0c4b637 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -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" @@ -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 diff --git a/packages/opencode/src/session/loop.ts b/packages/opencode/src/session/loop.ts new file mode 100644 index 000000000000..22b49688b579 --- /dev/null +++ b/packages/opencode/src/session/loop.ts @@ -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 = + "\nYour output is repeating in a loop with period ~{period} characters. Stop repeating and take a different, concrete action.\n" + +// 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 + }, + } +} diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index e8aab62d8423..f14090bd3b5f 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -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"), @@ -414,6 +425,7 @@ export namespace MessageV2 { AbortedError.Schema, StructuredOutputError.Schema, ContextOverflowError.Schema, + LoopError.Schema, APIError.Schema, ]) .optional(), diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 146c73f27712..7a85d8c35717 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -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" @@ -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 @@ -52,6 +53,7 @@ export namespace SessionProcessor { needsCompaction: boolean currentText: MessageV2.TextPart | undefined reasoningMap: Record + loopOutcome: LoopOutcome | undefined } type StreamEvent = Event @@ -99,6 +101,7 @@ export namespace SessionProcessor { needsCompaction: false, currentText: undefined, reasoningMap: {}, + loopOutcome: undefined, } let aborted = false @@ -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* () { @@ -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( @@ -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" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c29733999214..a92beb186ce9 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -31,10 +31,12 @@ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as Stream from "effect/Stream" import { Command } from "../command" import { pathToFileURL, fileURLToPath } from "url" +import { Config } from "../config/config" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" import { SessionProcessor } from "./processor" +import { isLoopOutcome, recovery as loopRecovery } from "./loop" import { Tool } from "@/tool/tool" import { Permission } from "@/permission" import { SessionStatus } from "./status" @@ -87,6 +89,7 @@ export namespace SessionPrompt { const provider = yield* Provider.Service const processor = yield* SessionProcessor.Service const compaction = yield* SessionCompaction.Service + const config = yield* Config.Service const plugin = yield* Plugin.Service const commands = yield* Command.Service const permission = yield* Permission.Service @@ -1341,6 +1344,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const ctx = yield* InstanceState.context let structured: unknown | undefined let step = 0 + let loopAttempt = 0 const session = yield* sessions.get(sessionID) while (true) { @@ -1522,6 +1526,53 @@ NOTE: At any point in time through this workflow you should feel free to ask the toolChoice: format.type === "json_schema" ? "required" : undefined, }) + if (isLoopOutcome(result)) { + const detection = (yield* config.get()).experimental?.loop + const decision = loopRecovery(loopAttempt, { + max_nudges: detection?.max_nudges, + reminder: detection?.reminder, + period: result.period, + }) + loopAttempt++ + + if (decision.action === "nudge") { + const reminder: MessageV2.User = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + time: { created: Date.now() }, + agent: lastUser.agent, + model: lastUser.model, + format: lastUser.format, + tools: lastUser.tools, + system: lastUser.system, + variant: lastUser.model.variant, + }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: reminder.id, + sessionID, + type: "text", + text: decision.reminder, + synthetic: true, + }) + return "continue" as const + } + + handle.message.error = new MessageV2.LoopError({ + message: `Repetitive ${result.source} output detected (period ~${result.period} chars) after ${decision.attempts} detection attempts`, + period: result.period, + attempts: decision.attempts, + action: "abort", + source: result.source, + }).toObject() + handle.message.finish = "error" + yield* sessions.updateMessage(handle.message) + return "break" as const + } + + loopAttempt = 0 + if (structured !== undefined) { handle.message.structured = structured handle.message.finish = handle.message.finish ?? "stop" @@ -1731,6 +1782,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the Layer.provide(Session.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Bus.layer), + Layer.provide(Config.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), ), ), diff --git a/packages/opencode/test/session/loop-integration.test.ts b/packages/opencode/test/session/loop-integration.test.ts new file mode 100644 index 000000000000..77ccb2157a5e --- /dev/null +++ b/packages/opencode/test/session/loop-integration.test.ts @@ -0,0 +1,254 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Agent as AgentSvc } from "../../src/agent/agent" +import { Bus } from "../../src/bus" +import { Command } from "../../src/command" +import { Config } from "../../src/config/config" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { FileTime } from "../../src/file/time" +import { AppFileSystem } from "../../src/filesystem" +import { LSP } from "../../src/lsp" +import { MCP } from "../../src/mcp" +import { Permission } from "../../src/permission" +import { Plugin } from "../../src/plugin" +import { Provider as ProviderSvc } from "../../src/provider/provider" +import { Question } from "../../src/question" +import { Session } from "../../src/session" +import { SessionCompaction } from "../../src/session/compaction" +import { Instruction } from "../../src/session/instruction" +import { LLM } from "../../src/session/llm" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionProcessor } from "../../src/session/processor" +import { SessionPrompt } from "../../src/session/prompt" +import { SessionStatus } from "../../src/session/status" +import { Todo } from "../../src/session/todo" +import { Snapshot } from "../../src/snapshot" +import { ToolRegistry } from "../../src/tool/registry" +import { Truncate } from "../../src/tool/truncate" +import { Log } from "../../src/util/log" +import { provideTmpdirServer } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { reply, TestLLMServer } from "../lib/llm-server" + +Log.init({ print: false }) + +const mcp = Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + add: () => Effect.succeed({ status: { status: "disabled" as const } }), + connect: () => Effect.void, + disconnect: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource: () => Effect.succeed(undefined), + startAuth: () => Effect.die("unexpected MCP auth"), + authenticate: () => Effect.die("unexpected MCP auth"), + finishAuth: () => Effect.die("unexpected MCP auth"), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed("not_authenticated" as const), + }), +) + +const lsp = Layer.succeed( + LSP.Service, + LSP.Service.of({ + init: () => Effect.void, + status: () => Effect.succeed([]), + hasClients: () => Effect.succeed(false), + touchFile: () => Effect.void, + diagnostics: () => Effect.succeed({}), + hover: () => Effect.succeed(undefined), + definition: () => Effect.succeed([]), + references: () => Effect.succeed([]), + implementation: () => Effect.succeed([]), + documentSymbol: () => Effect.succeed([]), + workspaceSymbol: () => Effect.succeed([]), + prepareCallHierarchy: () => Effect.succeed([]), + incomingCalls: () => Effect.succeed([]), + outgoingCalls: () => Effect.succeed([]), + }), +) + +const filetime = Layer.succeed( + FileTime.Service, + FileTime.Service.of({ + read: () => Effect.void, + get: () => Effect.succeed(undefined), + assert: () => Effect.void, + withLock: (_filepath, fn) => Effect.promise(fn), + }), +) + +const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) + +function makeHttp() { + const deps = Layer.mergeAll( + Session.defaultLayer, + Snapshot.defaultLayer, + LLM.defaultLayer, + AgentSvc.defaultLayer, + Command.defaultLayer, + Permission.defaultLayer, + Plugin.defaultLayer, + Config.defaultLayer, + ProviderSvc.defaultLayer, + filetime, + lsp, + mcp, + AppFileSystem.defaultLayer, + status, + ).pipe(Layer.provideMerge(infra)) + const question = Question.layer.pipe(Layer.provideMerge(deps)) + const todo = Todo.layer.pipe(Layer.provideMerge(deps)) + const registry = ToolRegistry.layer.pipe( + Layer.provideMerge(todo), + Layer.provideMerge(question), + Layer.provideMerge(deps), + ) + const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps)) + const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) + return Layer.mergeAll( + TestLLMServer.layer, + SessionPrompt.layer.pipe( + Layer.provideMerge(compact), + Layer.provideMerge(proc), + Layer.provideMerge(registry), + Layer.provideMerge(trunc), + Layer.provide(Instruction.defaultLayer), + Layer.provideMerge(deps), + ), + ) +} + +const it = testEffect(makeHttp()) + +// Build a repeating text payload that triggers loop detection with the given +// config thresholds. The segment is repeated twice so the detector sees two +// adjacent identical blocks. +function looping(period: number) { + const segment = "abcdefghij".repeat(Math.ceil(period / 10)).slice(0, period) + return segment + segment +} + +// Config that registers a custom "test" provider and sets very low loop +// detection thresholds so the detector fires quickly. +function config(url: string) { + return { + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: url, + }, + }, + }, + experimental: { + loop: { + enabled: true, + min_period: 10, + max_period: 200, + similarity: 1.0, + check_interval: 1, + min_chars: 20, + max_nudges: 1, + }, + }, + } +} + +const PERIOD = 50 + +describe("loop-integration", () => { + it.live( + "detect -> nudge -> detect -> abort across the prompt pipeline", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + const session = yield* sessions.create({ + title: "Loop test", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + // Queue the user message + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + + // First model response: repeating text → triggers loop detection → nudge + yield* llm.push(reply().text(looping(PERIOD)).stop()) + // Second model response (after nudge): repeating text again → triggers loop detection → abort + yield* llm.push(reply().text(looping(PERIOD)).stop()) + + const result = yield* prompt.loop({ sessionID: session.id }) + + // --- Assertion 1: final assistant message has LoopError --- + expect(result.info.role).toBe("assistant") + if (result.info.role !== "assistant") return + expect(result.info.error).toBeDefined() + expect(result.info.error?.name).toBe("LoopError") + + // --- Assertion 4: LoopError.data.source === "text" --- + const data = result.info.error?.data as { source?: string; attempts?: number } | undefined + expect(data?.source).toBe("text") + + // --- Assertion 5: LoopError.data.attempts === 2 --- + expect(data?.attempts).toBe(2) + + // --- Assertions 2 & 6: exactly one synthetic reminder exists --- + const msgs = yield* MessageV2.filterCompactedEffect(session.id) + const reminders = msgs.filter( + (m) => + m.info.role === "user" && + m.parts.some((p) => p.type === "text" && p.synthetic && p.text.includes("repeating")), + ) + expect(reminders).toHaveLength(1) + + // --- Assertion 3: second model request contains the persisted --- + // The test LLM server captures all request bodies. The title request is + // auto-handled, so we filter to non-title hits. The second non-title + // request should contain the reminder text in its messages. + const inputs = yield* llm.inputs + // Filter out title-generation requests + const model = inputs.filter((body) => !JSON.stringify(body).includes("Generate a title")) + expect(model.length).toBeGreaterThanOrEqual(2) + const second = JSON.stringify(model[1]) + expect(second).toContain("repeating") + }), + { git: true, config }, + ), + 15_000, + ) +}) diff --git a/packages/opencode/test/session/loop.test.ts b/packages/opencode/test/session/loop.test.ts new file mode 100644 index 000000000000..e8e550fc3295 --- /dev/null +++ b/packages/opencode/test/session/loop.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, test } from "bun:test" +import { create, recovery, isLoopOutcome, DEFAULTS, type LoopOutcome } from "../../src/session/loop" +import { Config } from "../../src/config/config" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function repeat(s: string, n: number) { + return s.repeat(n) +} + +function drain(detector: ReturnType, text: string, chunk = 1) { + let result: LoopOutcome | undefined + for (let i = 0; i < text.length; i += chunk) { + const r = detector.feed(text.slice(i, i + chunk)) + if (r) result = r + } + return result +} + +// --------------------------------------------------------------------------- +// Detector — exact repeating block +// --------------------------------------------------------------------------- + +describe("loop detector", () => { + test("detects exact repeating block", () => { + const block = "The quick brown fox jumps over the lazy dog. " + const detector = create({ + source: "text", + min_period: 10, + max_period: 200, + similarity: 1.0, + check_interval: 1, + min_chars: 0, + }) + const result = drain(detector, repeat(block, 4)) + expect(result).toBeDefined() + expect(result!.type).toBe("loop") + expect(result!.source).toBe("text") + }) + + test("detects near-identical block with similarity threshold", () => { + const a = "The quick brown fox jumps over the lazy dog. " + const b = "The quick brown fox jumps over the lazy cat. " + const detector = create({ + source: "text", + min_period: 10, + max_period: 200, + similarity: 0.8, + check_interval: 1, + min_chars: 0, + }) + const result = drain(detector, a + b) + expect(result).toBeDefined() + expect(result!.type).toBe("loop") + }) + + test("no detection below min_chars", () => { + const block = "abc " + const detector = create({ + source: "text", + min_period: 2, + max_period: 20, + similarity: 1.0, + check_interval: 1, + min_chars: 9999, + }) + const result = drain(detector, repeat(block, 10)) + expect(result).toBeUndefined() + }) + + test("no detection below min_period", () => { + const block = "ab" + const detector = create({ + source: "text", + min_period: 100, + max_period: 200, + similarity: 1.0, + check_interval: 1, + min_chars: 0, + }) + const result = drain(detector, repeat(block, 60)) + expect(result).toBeUndefined() + }) + + test("no detection for punctuation/symbol repeats", () => { + const detector = create({ + source: "text", + min_period: 3, + max_period: 200, + similarity: 1.0, + check_interval: 1, + min_chars: 0, + }) + expect(drain(detector, repeat("---", 40))).toBeUndefined() + }) + + test("no detection for markdown table separator repeats", () => { + const detector = create({ + source: "text", + min_period: 3, + max_period: 200, + similarity: 1.0, + check_interval: 1, + min_chars: 0, + }) + expect(drain(detector, repeat("| --- ", 40))).toBeUndefined() + }) + + test("no detection for varied content", () => { + const detector = create({ + source: "text", + min_period: 10, + max_period: 200, + similarity: 1.0, + check_interval: 1, + min_chars: 0, + }) + const varied = Array.from({ length: 20 }, (_, i) => `Sentence number ${i} is unique. `).join("") + expect(drain(detector, varied)).toBeUndefined() + }) + + test("detects long repeating block", () => { + const block = "A".repeat(50) + " long block of text that repeats itself over and over. " + const detector = create({ + source: "reasoning", + min_period: 10, + max_period: 2000, + similarity: 1.0, + check_interval: 1, + min_chars: 0, + }) + const result = drain(detector, repeat(block, 3)) + expect(result).toBeDefined() + expect(result!.source).toBe("reasoning") + }) + + test("detects short repeating block", () => { + const block = "hello world " + const detector = create({ + source: "text", + min_period: 5, + max_period: 200, + similarity: 1.0, + check_interval: 1, + min_chars: 0, + }) + const result = drain(detector, repeat(block, 10)) + expect(result).toBeDefined() + }) + + test("detects Unicode loop", () => { + const block = "\u4F60\u597D\u4E16\u754C\u3002\u8FD9\u662F\u91CD\u590D\u7684\u5185\u5BB9\u3002" + const detector = create({ + source: "text", + min_period: 5, + max_period: 200, + similarity: 1.0, + check_interval: 1, + min_chars: 0, + }) + const result = drain(detector, repeat(block, 6)) + expect(result).toBeDefined() + expect(result!.type).toBe("loop") + }) + + test("filters Unicode punctuation-only repeats", () => { + const detector = create({ + source: "text", + min_period: 3, + max_period: 200, + similarity: 1.0, + check_interval: 1, + min_chars: 0, + }) + expect(drain(detector, repeat("\u3001\u3002\uFF01", 40))).toBeUndefined() + }) + + test("reset clears state", () => { + const block = "repeating content here. " + const detector = create({ + source: "text", + min_period: 10, + max_period: 200, + similarity: 1.0, + check_interval: 1, + min_chars: 0, + }) + // Feed enough to detect + const first = drain(detector, repeat(block, 10)) + expect(first).toBeDefined() + + // Reset and feed non-repeating content + detector.reset() + const varied = Array.from({ length: 10 }, (_, i) => `Unique sentence ${i}. `).join("") + expect(drain(detector, varied)).toBeUndefined() + }) + + test("on_detected callback fires", () => { + const block = "callback test content. " + const outcomes: LoopOutcome[] = [] + const detector = create({ + source: "text", + min_period: 10, + max_period: 200, + similarity: 1.0, + check_interval: 1, + min_chars: 0, + on_detected: (o) => outcomes.push(o), + }) + drain(detector, repeat(block, 10)) + expect(outcomes.length).toBeGreaterThan(0) + expect(outcomes[0].type).toBe("loop") + }) +}) + +// --------------------------------------------------------------------------- +// Recovery +// --------------------------------------------------------------------------- + +describe("recovery", () => { + test("attempt 0 returns nudge", () => { + const r = recovery(0) + expect(r.action).toBe("nudge") + if (r.action === "nudge") { + expect(typeof r.reminder).toBe("string") + expect(r.reminder.length).toBeGreaterThan(0) + } + }) + + test("attempt 1 returns abort (default max_nudges=1)", () => { + const r = recovery(1) + expect(r.action).toBe("abort") + if (r.action === "abort") { + expect(r.attempts).toBe(2) + } + }) + + test("attempt 0 returns abort when max_nudges=0", () => { + const r = recovery(0, { max_nudges: 0 }) + expect(r.action).toBe("abort") + if (r.action === "abort") { + expect(r.attempts).toBe(1) + } + }) + + test("nudge reminder includes period", () => { + const r = recovery(0, { period: 42 }) + if (r.action === "nudge") { + expect(r.reminder).toContain("42") + } + }) + + test("custom reminder template", () => { + const r = recovery(0, { reminder: "Loop at {period} chars", period: 100 }) + if (r.action === "nudge") { + expect(r.reminder).toBe("Loop at 100 chars") + } + }) +}) + +// --------------------------------------------------------------------------- +// isLoopOutcome +// --------------------------------------------------------------------------- + +describe("isLoopOutcome", () => { + test("returns true for valid outcome", () => { + expect(isLoopOutcome({ type: "loop", period: 10, source: "text" })).toBe(true) + }) + + test("returns false for non-objects", () => { + expect(isLoopOutcome(null)).toBe(false) + expect(isLoopOutcome(undefined)).toBe(false) + expect(isLoopOutcome("loop")).toBe(false) + expect(isLoopOutcome(42)).toBe(false) + }) + + test("returns false for wrong type field", () => { + expect(isLoopOutcome({ type: "other" })).toBe(false) + expect(isLoopOutcome({})).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Privacy — no raw text in outcome +// --------------------------------------------------------------------------- + +describe("privacy", () => { + test("outcome does not contain raw repeated text", () => { + const secret = "sensitive data that should not leak. " + const detector = create({ + source: "text", + min_period: 10, + max_period: 200, + similarity: 1.0, + check_interval: 1, + min_chars: 0, + }) + const result = drain(detector, repeat(secret, 10)) + expect(result).toBeDefined() + const json = JSON.stringify(result) + expect(json).not.toContain("sensitive") + expect(json).not.toContain("leak") + }) +}) + +// --------------------------------------------------------------------------- +// Config validation — loop schema +// --------------------------------------------------------------------------- + +describe("loop config validation", () => { + const loop = Config.Info.shape.experimental.unwrap().shape.loop + + test("accepts valid config", () => { + const result = loop.safeParse({ min_period: 10, max_period: 100, similarity: 0.9 }) + expect(result.success).toBe(true) + }) + + test("rejects min_period > max_period", () => { + const result = loop.safeParse({ min_period: 200, max_period: 100 }) + expect(result.success).toBe(false) + }) + + test("accepts min_period == max_period", () => { + const result = loop.safeParse({ min_period: 50, max_period: 50 }) + expect(result.success).toBe(true) + }) + + test("rejects similarity > 1", () => { + const result = loop.safeParse({ similarity: 1.5 }) + expect(result.success).toBe(false) + }) + + test("rejects similarity < 0", () => { + const result = loop.safeParse({ similarity: -0.1 }) + expect(result.success).toBe(false) + }) + + test("accepts similarity at boundaries", () => { + expect(loop.safeParse({ similarity: 0 }).success).toBe(true) + expect(loop.safeParse({ similarity: 1 }).success).toBe(true) + }) + + test("accepts empty config", () => { + expect(loop.safeParse({}).success).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// DEFAULTS +// --------------------------------------------------------------------------- + +describe("DEFAULTS", () => { + test("has expected keys", () => { + expect(DEFAULTS.min_period).toBeGreaterThan(0) + expect(DEFAULTS.max_period).toBeGreaterThan(DEFAULTS.min_period) + expect(DEFAULTS.similarity).toBeGreaterThanOrEqual(0) + expect(DEFAULTS.similarity).toBeLessThanOrEqual(1) + expect(DEFAULTS.check_interval).toBeGreaterThan(0) + expect(DEFAULTS.min_chars).toBeGreaterThan(0) + expect(DEFAULTS.max_nudges).toBeGreaterThanOrEqual(0) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0a9aa4358ec5..8e4ebb9f1e1c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -408,6 +408,17 @@ export type ContextOverflowError = { } } +export type LoopError = { + name: "LoopError" + data: { + message: string + period: number + attempts: number + action: "abort" + source: "reasoning" | "text" + } +} + export type ApiError = { name: "APIError" data: { @@ -435,6 +446,7 @@ export type EventSessionError = { | MessageAbortedError | StructuredOutputError | ContextOverflowError + | LoopError | ApiError } } @@ -570,6 +582,7 @@ export type AssistantMessage = { | MessageAbortedError | StructuredOutputError | ContextOverflowError + | LoopError | ApiError parentID: string modelID: string @@ -1615,6 +1628,43 @@ export type Config = { * Timeout in milliseconds for model context protocol (MCP) requests */ mcp_timeout?: number + /** + * Loop detection configuration for reasoning and text streams + */ + loop?: { + /** + * Enable loop detection for reasoning and text streams + */ + enabled?: boolean + /** + * Minimum repeating pattern length in characters + */ + min_period?: number + /** + * Maximum repeating pattern length in characters + */ + max_period?: number + /** + * Similarity threshold for near-identical repetition detection (0-1) + */ + similarity?: number + /** + * Number of characters between detection checks + */ + check_interval?: number + /** + * Minimum characters received before detection starts + */ + min_chars?: number + /** + * Maximum nudge attempts before aborting on loop detection + */ + max_nudges?: number + /** + * Custom reminder message injected on nudge + */ + reminder?: string + } } }