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.`, + }) + } } }