From 1fdce01fd225c1d27466623d81d665944c844906 Mon Sep 17 00:00:00 2001 From: tad-hq <213478119+tad-hq@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:36:09 -0600 Subject: [PATCH] fix(circuit-breaker): target-aware loop detection via tool signatures --- .../background-agent/loop-detector.test.ts | 96 +++++++++++++++++++ .../background-agent/loop-detector.ts | 46 +++++++-- src/features/background-agent/types.ts | 2 +- 3 files changed, 134 insertions(+), 10 deletions(-) diff --git a/src/features/background-agent/loop-detector.test.ts b/src/features/background-agent/loop-detector.test.ts index a1355f3c9..4d0bad4b3 100644 --- a/src/features/background-agent/loop-detector.test.ts +++ b/src/features/background-agent/loop-detector.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { + createToolCallSignature, detectRepetitiveToolUse, recordToolCall, resolveCircuitBreakerSettings, @@ -17,6 +18,17 @@ function buildWindow( ) } +function buildWindowWithInputs( + calls: Array<{ tool: string; input?: Record }>, + override?: Parameters[0] +) { + const settings = resolveCircuitBreakerSettings(override) + return calls.reduce( + (window, { tool, input }) => recordToolCall(window, tool, settings, input), + undefined as ReturnType | undefined + ) +} + describe("loop-detector", () => { describe("resolveCircuitBreakerSettings", () => { describe("#given nested circuit breaker config", () => { @@ -84,6 +96,39 @@ describe("loop-detector", () => { }) }) + describe("createToolCallSignature", () => { + test("#given tool with input #when signature created #then includes tool and sorted input", () => { + const result = createToolCallSignature("read", { filePath: "/a.ts" }) + + expect(result).toBe('read::{"filePath":"/a.ts"}') + }) + + test("#given tool with undefined input #when signature created #then returns bare tool name", () => { + const result = createToolCallSignature("read", undefined) + + expect(result).toBe("read") + }) + + test("#given tool with null input #when signature created #then returns bare tool name", () => { + const result = createToolCallSignature("read", null) + + expect(result).toBe("read") + }) + + test("#given tool with empty object input #when signature created #then returns bare tool name", () => { + const result = createToolCallSignature("read", {}) + + expect(result).toBe("read") + }) + + test("#given same input different key order #when signatures compared #then they are equal", () => { + const first = createToolCallSignature("read", { filePath: "/a.ts", offset: 0 }) + const second = createToolCallSignature("read", { offset: 0, filePath: "/a.ts" }) + + expect(first).toBe(second) + }) + }) + describe("detectRepetitiveToolUse", () => { describe("#given recent tools are diverse", () => { test("#when evaluated #then it does not trigger", () => { @@ -158,5 +203,56 @@ describe("loop-detector", () => { }) }) }) + + describe("#given same tool with different file inputs", () => { + test("#when evaluated #then it does not trigger", () => { + const calls = Array.from({ length: 20 }, (_, i) => ({ + tool: "read", + input: { filePath: `/src/file-${i}.ts` }, + })) + const window = buildWindowWithInputs(calls, { + circuitBreaker: { windowSize: 20, repetitionThresholdPercent: 80 }, + }) + const result = detectRepetitiveToolUse(window) + expect(result.triggered).toBe(false) + }) + }) + + describe("#given same tool with identical file inputs", () => { + test("#when evaluated #then it triggers with bare tool name", () => { + const calls = [ + ...Array.from({ length: 16 }, () => ({ tool: "read", input: { filePath: "/src/same.ts" } })), + { tool: "grep", input: { pattern: "foo" } }, + { tool: "edit", input: { filePath: "/src/other.ts" } }, + { tool: "bash", input: { command: "ls" } }, + { tool: "glob", input: { pattern: "**/*.ts" } }, + ] + const window = buildWindowWithInputs(calls, { + circuitBreaker: { windowSize: 20, repetitionThresholdPercent: 80 }, + }) + const result = detectRepetitiveToolUse(window) + expect(result.triggered).toBe(true) + expect(result.toolName).toBe("read") + expect(result.repeatedCount).toBe(16) + }) + }) + + describe("#given tool calls with no input", () => { + test("#when the same tool dominates #then falls back to name-only detection", () => { + const calls = [ + ...Array.from({ length: 16 }, () => ({ tool: "read" })), + { tool: "grep" }, + { tool: "edit" }, + { tool: "bash" }, + { tool: "glob" }, + ] + const window = buildWindowWithInputs(calls, { + circuitBreaker: { windowSize: 20, repetitionThresholdPercent: 80 }, + }) + const result = detectRepetitiveToolUse(window) + expect(result.triggered).toBe(true) + expect(result.toolName).toBe("read") + }) + }) }) }) diff --git a/src/features/background-agent/loop-detector.ts b/src/features/background-agent/loop-detector.ts index a41597825..af93dee2a 100644 --- a/src/features/background-agent/loop-detector.ts +++ b/src/features/background-agent/loop-detector.ts @@ -39,28 +39,56 @@ export function resolveCircuitBreakerSettings( export function recordToolCall( window: ToolCallWindow | undefined, toolName: string, - settings: CircuitBreakerSettings + settings: CircuitBreakerSettings, + toolInput?: Record | null ): ToolCallWindow { - const previous = window?.toolNames ?? [] - const toolNames = [...previous, toolName].slice(-settings.windowSize) + const previous = window?.toolSignatures ?? [] + const signature = createToolCallSignature(toolName, toolInput) + const toolSignatures = [...previous, signature].slice(-settings.windowSize) return { - toolNames, + toolSignatures, windowSize: settings.windowSize, thresholdPercent: settings.repetitionThresholdPercent, } } +function sortObject(obj: unknown): unknown { + if (obj === null || obj === undefined) return obj + if (typeof obj !== "object") return obj + if (Array.isArray(obj)) return obj.map(sortObject) + + const sorted: Record = {} + const keys = Object.keys(obj as Record).sort() + for (const key of keys) { + sorted[key] = sortObject((obj as Record)[key]) + } + return sorted +} + +export function createToolCallSignature( + toolName: string, + toolInput?: Record | null +): string { + if (toolInput === undefined || toolInput === null) { + return toolName + } + if (Object.keys(toolInput).length === 0) { + return toolName + } + return `${toolName}::${JSON.stringify(sortObject(toolInput))}` +} + export function detectRepetitiveToolUse( window: ToolCallWindow | undefined ): ToolLoopDetectionResult { - if (!window || window.toolNames.length === 0) { + if (!window || window.toolSignatures.length === 0) { return { triggered: false } } const counts = new Map() - for (const toolName of window.toolNames) { - counts.set(toolName, (counts.get(toolName) ?? 0) + 1) + for (const signature of window.toolSignatures) { + counts.set(signature, (counts.get(signature) ?? 0) + 1) } let repeatedTool: string | undefined @@ -73,7 +101,7 @@ export function detectRepetitiveToolUse( } } - const sampleSize = window.toolNames.length + const sampleSize = window.toolSignatures.length const minimumSampleSize = Math.min( window.windowSize, Math.ceil((window.windowSize * window.thresholdPercent) / 100) @@ -91,7 +119,7 @@ export function detectRepetitiveToolUse( return { triggered: true, - toolName: repeatedTool, + toolName: repeatedTool.split("::")[0], repeatedCount, sampleSize, thresholdPercent: window.thresholdPercent, diff --git a/src/features/background-agent/types.ts b/src/features/background-agent/types.ts index 7129aa2fd..be57d5a7d 100644 --- a/src/features/background-agent/types.ts +++ b/src/features/background-agent/types.ts @@ -10,7 +10,7 @@ export type BackgroundTaskStatus = | "interrupt" export interface ToolCallWindow { - toolNames: string[] + toolSignatures: string[] windowSize: number thresholdPercent: number }