fix(background-agent): prevent circuit breaker false positives on flat-format events

This commit is contained in:
Mou
2026-03-24 16:20:10 +08:00
committed by guazi04
parent 500784a9b9
commit b9fa2a3ebc
3 changed files with 51 additions and 18 deletions
@@ -1,3 +1,5 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { import {
createToolCallSignature, createToolCallSignature,
@@ -19,7 +21,7 @@ function buildWindow(
} }
function buildWindowWithInputs( function buildWindowWithInputs(
calls: Array<{ tool: string; input?: Record<string, unknown> }>, calls: Array<{ tool: string; input?: Record<string, unknown> | null }>,
override?: Parameters<typeof resolveCircuitBreakerSettings>[0] override?: Parameters<typeof resolveCircuitBreakerSettings>[0]
) { ) {
const settings = resolveCircuitBreakerSettings(override) const settings = resolveCircuitBreakerSettings(override)
@@ -148,7 +150,12 @@ describe("loop-detector", () => {
describe("#given the same tool is called consecutively", () => { describe("#given the same tool is called consecutively", () => {
test("#when evaluated #then it triggers", () => { test("#when evaluated #then it triggers", () => {
const window = buildWindow(Array.from({ length: 20 }, () => "read")) const window = buildWindowWithInputs(
Array.from({ length: 20 }, () => ({
tool: "read",
input: { filePath: "/src/same.ts" },
}))
)
const result = detectRepetitiveToolUse(window) const result = detectRepetitiveToolUse(window)
@@ -176,7 +183,12 @@ describe("loop-detector", () => {
describe("#given threshold boundary", () => { describe("#given threshold boundary", () => {
test("#when below threshold #then it does not trigger", () => { test("#when below threshold #then it does not trigger", () => {
const belowThresholdWindow = buildWindow(Array.from({ length: 19 }, () => "read")) const belowThresholdWindow = buildWindowWithInputs(
Array.from({ length: 19 }, () => ({
tool: "read",
input: { filePath: "/src/same.ts" },
}))
)
const result = detectRepetitiveToolUse(belowThresholdWindow) const result = detectRepetitiveToolUse(belowThresholdWindow)
@@ -184,7 +196,12 @@ describe("loop-detector", () => {
}) })
test("#when equal to threshold #then it triggers", () => { test("#when equal to threshold #then it triggers", () => {
const atThresholdWindow = buildWindow(Array.from({ length: 20 }, () => "read")) const atThresholdWindow = buildWindowWithInputs(
Array.from({ length: 20 }, () => ({
tool: "read",
input: { filePath: "/src/same.ts" },
}))
)
const result = detectRepetitiveToolUse(atThresholdWindow) const result = detectRepetitiveToolUse(atThresholdWindow)
@@ -224,16 +241,22 @@ describe("loop-detector", () => {
}) })
}) })
describe("#given tool calls with no input", () => { describe("#given tool calls with undefined input", () => {
test("#when evaluated #then it triggers", () => { test("#when evaluated #then it does not trigger", () => {
const calls = Array.from({ length: 20 }, () => ({ tool: "read" })) const calls = Array.from({ length: 20 }, () => ({ tool: "read" }))
const window = buildWindowWithInputs(calls) const window = buildWindowWithInputs(calls)
const result = detectRepetitiveToolUse(window) const result = detectRepetitiveToolUse(window)
expect(result).toEqual({ expect(result).toEqual({ triggered: false })
triggered: true, })
toolName: "read", })
repeatedCount: 20,
}) describe("#given tool calls with null input", () => {
test("#when evaluated #then it does not trigger", () => {
const calls = Array.from({ length: 20 }, () => ({ tool: "read", input: null }))
const window = buildWindowWithInputs(calls)
const result = detectRepetitiveToolUse(window)
expect(result).toEqual({ triggered: false })
}) })
}) })
}) })
@@ -36,6 +36,14 @@ export function recordToolCall(
settings: CircuitBreakerSettings, settings: CircuitBreakerSettings,
toolInput?: Record<string, unknown> | null toolInput?: Record<string, unknown> | null
): ToolCallWindow { ): ToolCallWindow {
if (toolInput === undefined || toolInput === null) {
return {
lastSignature: `${toolName}::__unknown-input__`,
consecutiveCount: 1,
threshold: settings.consecutiveThreshold,
}
}
const signature = createToolCallSignature(toolName, toolInput) const signature = createToolCallSignature(toolName, toolInput)
if (window && window.lastSignature === signature) { if (window && window.lastSignature === signature) {
@@ -1,3 +1,5 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
@@ -38,8 +40,8 @@ async function flushAsyncWork() {
} }
describe("BackgroundManager circuit breaker", () => { describe("BackgroundManager circuit breaker", () => {
describe("#given the same tool is called consecutively", () => { describe("#given flat-format tool events have no state.input", () => {
test("#when consecutive tool events arrive #then the task is cancelled", async () => { test("#when 20 consecutive read events arrive #then the task keeps running", async () => {
const manager = createManager({ const manager = createManager({
circuitBreaker: { circuitBreaker: {
consecutiveThreshold: 20, consecutiveThreshold: 20,
@@ -71,8 +73,8 @@ describe("BackgroundManager circuit breaker", () => {
await flushAsyncWork() await flushAsyncWork()
expect(task.status).toBe("cancelled") expect(task.status).toBe("running")
expect(task.error).toContain("read 20 consecutive times") expect(task.progress?.toolCalls).toBe(20)
}) })
}) })
@@ -126,7 +128,7 @@ describe("BackgroundManager circuit breaker", () => {
}) })
describe("#given the absolute cap is configured lower than the repetition detector needs", () => { describe("#given the absolute cap is configured lower than the repetition detector needs", () => {
test("#when the raw tool-call cap is reached #then the backstop still cancels the task", async () => { test("#when repeated flat-format tool events reach maxToolCalls #then the backstop still cancels the task", async () => {
const manager = createManager({ const manager = createManager({
maxToolCalls: 3, maxToolCalls: 3,
circuitBreaker: { circuitBreaker: {
@@ -150,10 +152,10 @@ describe("BackgroundManager circuit breaker", () => {
} }
getTaskMap(manager).set(task.id, task) getTaskMap(manager).set(task.id, task)
for (const toolName of ["read", "grep", "edit"]) { for (let i = 0; i < 3; i++) {
manager.handleEvent({ manager.handleEvent({
type: "message.part.updated", type: "message.part.updated",
properties: { sessionID: task.sessionID, type: "tool", tool: toolName }, properties: { sessionID: task.sessionID, type: "tool", tool: "read" },
}) })
} }