From 3055454ecce76638baced396096d9746875fb776 Mon Sep 17 00:00:00 2001 From: MoerAI Date: Mon, 16 Mar 2026 10:28:38 +0900 Subject: [PATCH] fix(background-agent): add circuit breaker to prevent subagent infinite loops Adds a configurable maxToolCalls limit (default: 200) that automatically cancels background tasks when they exceed the threshold. This prevents runaway subagent loops from burning unlimited tokens, as reported in #2571 where a Gemini subagent ran 809 consecutive tool calls over 3.5 hours costing ~$350. The circuit breaker triggers in the existing tool call tracking path (message.part.updated/delta events) and cancels the task with a clear error message explaining what happened. The limit is configurable via background_task.maxToolCalls in oh-my-opencode.jsonc. Fixes #2571 --- src/config/schema/background-task.ts | 2 ++ src/features/background-agent/manager.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/config/schema/background-task.ts b/src/config/schema/background-task.ts index 0945d7595..f98040e2d 100644 --- a/src/config/schema/background-task.ts +++ b/src/config/schema/background-task.ts @@ -11,6 +11,8 @@ export const BackgroundTaskConfigSchema = z.object({ /** Timeout for tasks that never received any progress update, falling back to startedAt (default: 1800000 = 30 minutes, minimum: 60000 = 1 minute) */ messageStalenessTimeoutMs: z.number().min(60000).optional(), syncPollTimeoutMs: z.number().min(60000).optional(), + /** Maximum tool calls per subagent task before circuit breaker triggers (default: 200, minimum: 10). Prevents runaway loops from burning unlimited tokens. */ + maxToolCalls: z.number().int().min(10).optional(), }) export type BackgroundTaskConfig = z.infer diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 0947b2d17..39e2dc420 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -878,6 +878,21 @@ export class BackgroundManager { if (partInfo?.type === "tool" || partInfo?.tool) { task.progress.toolCalls += 1 task.progress.lastTool = partInfo.tool + + const maxToolCalls = this.config?.maxToolCalls ?? 200 + if (task.progress.toolCalls >= maxToolCalls) { + log("[background-agent] Circuit breaker: tool call limit reached", { + taskId: task.id, + toolCalls: task.progress.toolCalls, + maxToolCalls, + agent: task.agent, + sessionID, + }) + void this.cancelTask(task.id, { + source: "circuit-breaker", + reason: `Subagent exceeded maximum tool call limit (${maxToolCalls}). This usually indicates an infinite loop. The task was automatically cancelled to prevent excessive token usage.`, + }) + } } }