fix(circuit-breaker): target-aware loop detection via tool signatures

This commit is contained in:
tad-hq
2026-03-17 13:36:09 -06:00
parent c8213c970e
commit 1fdce01fd2
3 changed files with 134 additions and 10 deletions
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { import {
createToolCallSignature,
detectRepetitiveToolUse, detectRepetitiveToolUse,
recordToolCall, recordToolCall,
resolveCircuitBreakerSettings, resolveCircuitBreakerSettings,
@@ -17,6 +18,17 @@ function buildWindow(
) )
} }
function buildWindowWithInputs(
calls: Array<{ tool: string; input?: Record<string, unknown> }>,
override?: Parameters<typeof resolveCircuitBreakerSettings>[0]
) {
const settings = resolveCircuitBreakerSettings(override)
return calls.reduce(
(window, { tool, input }) => recordToolCall(window, tool, settings, input),
undefined as ReturnType<typeof recordToolCall> | undefined
)
}
describe("loop-detector", () => { describe("loop-detector", () => {
describe("resolveCircuitBreakerSettings", () => { describe("resolveCircuitBreakerSettings", () => {
describe("#given nested circuit breaker config", () => { 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("detectRepetitiveToolUse", () => {
describe("#given recent tools are diverse", () => { describe("#given recent tools are diverse", () => {
test("#when evaluated #then it does not trigger", () => { 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")
})
})
}) })
}) })
+37 -9
View File
@@ -39,28 +39,56 @@ export function resolveCircuitBreakerSettings(
export function recordToolCall( export function recordToolCall(
window: ToolCallWindow | undefined, window: ToolCallWindow | undefined,
toolName: string, toolName: string,
settings: CircuitBreakerSettings settings: CircuitBreakerSettings,
toolInput?: Record<string, unknown> | null
): ToolCallWindow { ): ToolCallWindow {
const previous = window?.toolNames ?? [] const previous = window?.toolSignatures ?? []
const toolNames = [...previous, toolName].slice(-settings.windowSize) const signature = createToolCallSignature(toolName, toolInput)
const toolSignatures = [...previous, signature].slice(-settings.windowSize)
return { return {
toolNames, toolSignatures,
windowSize: settings.windowSize, windowSize: settings.windowSize,
thresholdPercent: settings.repetitionThresholdPercent, 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<string, unknown> = {}
const keys = Object.keys(obj as Record<string, unknown>).sort()
for (const key of keys) {
sorted[key] = sortObject((obj as Record<string, unknown>)[key])
}
return sorted
}
export function createToolCallSignature(
toolName: string,
toolInput?: Record<string, unknown> | 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( export function detectRepetitiveToolUse(
window: ToolCallWindow | undefined window: ToolCallWindow | undefined
): ToolLoopDetectionResult { ): ToolLoopDetectionResult {
if (!window || window.toolNames.length === 0) { if (!window || window.toolSignatures.length === 0) {
return { triggered: false } return { triggered: false }
} }
const counts = new Map<string, number>() const counts = new Map<string, number>()
for (const toolName of window.toolNames) { for (const signature of window.toolSignatures) {
counts.set(toolName, (counts.get(toolName) ?? 0) + 1) counts.set(signature, (counts.get(signature) ?? 0) + 1)
} }
let repeatedTool: string | undefined 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( const minimumSampleSize = Math.min(
window.windowSize, window.windowSize,
Math.ceil((window.windowSize * window.thresholdPercent) / 100) Math.ceil((window.windowSize * window.thresholdPercent) / 100)
@@ -91,7 +119,7 @@ export function detectRepetitiveToolUse(
return { return {
triggered: true, triggered: true,
toolName: repeatedTool, toolName: repeatedTool.split("::")[0],
repeatedCount, repeatedCount,
sampleSize, sampleSize,
thresholdPercent: window.thresholdPercent, thresholdPercent: window.thresholdPercent,
+1 -1
View File
@@ -10,7 +10,7 @@ export type BackgroundTaskStatus =
| "interrupt" | "interrupt"
export interface ToolCallWindow { export interface ToolCallWindow {
toolNames: string[] toolSignatures: string[]
windowSize: number windowSize: number
thresholdPercent: number thresholdPercent: number
} }