Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
13 changes: 13 additions & 0 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -407,6 +416,7 @@ export namespace MessageV2 {
AbortedError.Schema,
StructuredOutputError.Schema,
ContextOverflowError.Schema,
ThinkingLoopError.Schema,
APIError.Schema,
])
.optional(),
Expand Down
50 changes: 46 additions & 4 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, MessageV2.ReasoningPart> = {}
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) {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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", {
Expand Down
72 changes: 72 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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: ["<system-reminder>", input.reminder, "</system-reminder>"].join("\n"),
time: {
start: Date.now(),
end: Date.now(),
},
})
}

/** @internal Exported for testing */
export async function resolveTools(input: {
agent: Agent.Info
Expand Down
Loading
Loading