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
+ }
}
}