From 678b2510b22cfc0b97904886dee0711ebab41d1c Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Tue, 12 May 2026 00:29:10 +0800 Subject: [PATCH] fix: abort generic no-progress tool loops Abort generic repeated no-progress tool loops at the configured critical threshold when identical calls keep returning identical outcomes. Prepared head SHA: 7fa287cd0f88323f9f1b9c07dee7baa35b4d6a43 --- CHANGELOG.md | 1 + docs/tools/loop-detection.md | 4 +- .../pi-tools.before-tool-call.e2e.test.ts | 45 +++++++++---------- src/agents/tool-loop-detection.test.ts | 18 +++++--- src/agents/tool-loop-detection.ts | 19 +++++++- 5 files changed, 53 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d35bc839b248..baf3422984eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -276,6 +276,7 @@ Docs: https://docs.openclaw.ai - Agents/Pi: wait for embedded abort cleanup to settle before releasing the session write lock, preventing follow-up turns from racing previous prompt teardown. (#80239) Thanks @samzong. - WhatsApp: downgrade OpenClaw watchdog-triggered Web reconnects from runtime errors to recovery warnings and clear the recovered reconnect status after the next healthy connection. (#77026) Thanks @rubencu. - ACPX/Windows: hide the MCP proxy target child process window on Windows so ACP-backed agents do not flash or fail because of terminal window handling. Fixes #60672. (#60678) Thanks @KChow-ctrl. +- Agents: abort generic repeated no-progress tool loops at the critical threshold when identical calls keep returning identical outcomes. (#80668) Thanks @frankekn. ## 2026.5.9 diff --git a/docs/tools/loop-detection.md b/docs/tools/loop-detection.md index 256966603047..5d1dd0370203 100644 --- a/docs/tools/loop-detection.md +++ b/docs/tools/loop-detection.md @@ -77,10 +77,10 @@ Per-agent override (optional): | `enabled` | `false` | Master switch for the rolling-history detectors. Setting `false` also disables the post-compaction guard. | | `historySize` | `30` | Number of recent tool calls kept for analysis. | | `warningThreshold` | `10` | Threshold before a pattern is classified as warning-only. | -| `criticalThreshold` | `20` | Threshold for blocking repetitive loop patterns. | +| `criticalThreshold` | `20` | Threshold for blocking repetitive no-progress loop patterns. | | `unknownToolThreshold` | `10` | Block repeated calls to the same unavailable tool after this many misses. | | `globalCircuitBreakerThreshold` | `30` | Global no-progress breaker threshold across all detectors. | -| `detectors.genericRepeat` | `true` | Detects repeated same-tool + same-params patterns. | +| `detectors.genericRepeat` | `true` | Warns on repeated same-tool + same-params patterns and blocks when the same calls also return identical outcomes. | | `detectors.knownPollNoProgress` | `true` | Detects known polling-like patterns with no state change. | | `detectors.pingPong` | `true` | Detects alternating ping-pong patterns. | | `postCompactionGuard.windowSize` | `3` | Number of post-compaction tool calls during which the guard stays armed and the count of identical triples that aborts the run. | diff --git a/src/agents/pi-tools.before-tool-call.e2e.test.ts b/src/agents/pi-tools.before-tool-call.e2e.test.ts index 04c6adbdf75a..f5007023b2ac 100644 --- a/src/agents/pi-tools.before-tool-call.e2e.test.ts +++ b/src/agents/pi-tools.before-tool-call.e2e.test.ts @@ -15,7 +15,7 @@ import { runBeforeToolCallHook, wrapToolWithBeforeToolCallHook, } from "./pi-tools.before-tool-call.js"; -import { CRITICAL_THRESHOLD, GLOBAL_CIRCUIT_BREAKER_THRESHOLD } from "./tool-loop-detection.js"; +import { CRITICAL_THRESHOLD } from "./tool-loop-detection.js"; import type { AnyAgentTool } from "./tools/common.js"; import { callGatewayTool } from "./tools/gateway.js"; @@ -277,28 +277,23 @@ describe("before_tool_call loop detection behavior", () => { } }); - it("keeps generic repeated calls warn-only below global breaker", async () => { + it("keeps generic repeated calls unblocked below critical threshold", async () => { const { tool, params } = createGenericReadRepeatFixture(); - for (let i = 0; i < CRITICAL_THRESHOLD + 5; i += 1) { + for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) { await expectUnblockedToolExecution(tool, `read-${i}`, params); } }); - it("blocks generic repeated no-progress calls at global breaker threshold", async () => { + it("blocks generic repeated no-progress calls at critical threshold", async () => { const { tool, params } = createGenericReadRepeatFixture(); - for (let i = 0; i < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; i += 1) { + for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) { await expectUnblockedToolExecution(tool, `read-${i}`, params); } - const result = await tool.execute( - `read-${GLOBAL_CIRCUIT_BREAKER_THRESHOLD}`, - params, - undefined, - undefined, - ); - expectToolLoopBlockedResult(result, "global circuit breaker"); + const result = await tool.execute(`read-${CRITICAL_THRESHOLD}`, params, undefined, undefined); + expectToolLoopBlockedResult(result, "identical outcomes"); }); it("does not carry loop history across run ids", async () => { @@ -316,27 +311,27 @@ describe("before_tool_call loop detection behavior", () => { runId: "heartbeat-2", }); - for (let i = 0; i < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; i += 1) { + for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) { await expectUnblockedToolExecution(firstRunTool, `old-run-${i}`, params); } await expectUnblockedToolExecution(secondRunTool, "new-run-0", params); }); - it("coalesces repeated generic warning events into threshold buckets", async () => { - await withToolLoopEvents( - async (emitted) => { - const { tool, params } = createGenericReadRepeatFixture(); + it("escalates generic repeat diagnostics from warning to critical", async () => { + await withToolLoopEvents(async (emitted) => { + const { tool, params } = createGenericReadRepeatFixture(); - for (let i = 0; i < 21; i += 1) { - await tool.execute(`read-bucket-${i}`, params, undefined, undefined); - } + for (let i = 0; i < 21; i += 1) { + await tool.execute(`read-bucket-${i}`, params, undefined, undefined); + } - const genericWarns = emitted.filter((evt) => evt.detector === "generic_repeat"); - expect(genericWarns.map((evt) => evt.count)).toEqual([10, 20]); - }, - (evt) => evt.level === "warning", - ); + const genericEvents = emitted.filter((evt) => evt.detector === "generic_repeat"); + expect(genericEvents.map((evt) => [evt.level, evt.count])).toEqual([ + ["warning", 10], + ["critical", 20], + ]); + }); }); it("emits structured warning diagnostic events for ping-pong loops", async () => { diff --git a/src/agents/tool-loop-detection.test.ts b/src/agents/tool-loop-detection.test.ts index 18b782f15e7d..fcbcd2ae8849 100644 --- a/src/agents/tool-loop-detection.test.ts +++ b/src/agents/tool-loop-detection.test.ts @@ -388,7 +388,7 @@ describe("tool-loop-detection", () => { } }); - it("keeps generic loops warn-only below global breaker threshold", () => { + it("blocks generic no-progress loops at critical threshold", () => { const fixture = createReadNoProgressFixture(); const loopResult = detectLoopAfterRepeatedCalls({ toolName: fixture.toolName, @@ -398,7 +398,9 @@ describe("tool-loop-detection", () => { }); expect(loopResult.stuck).toBe(true); if (loopResult.stuck) { - expect(loopResult.level).toBe("warning"); + expect(loopResult.level).toBe("critical"); + expect(loopResult.detector).toBe("generic_repeat"); + expect(loopResult.message).toContain("identical outcomes"); } }); @@ -524,6 +526,10 @@ describe("tool-loop-detection", () => { toolParams: fixture.params, result: fixture.result, count: GLOBAL_CIRCUIT_BREAKER_THRESHOLD, + config: { + enabled: true, + detectors: { genericRepeat: false, knownPollNoProgress: true, pingPong: true }, + }, }); expect(loopResult.stuck).toBe(true); if (loopResult.stuck) { @@ -537,7 +543,7 @@ describe("tool-loop-detection", () => { const state = createState(); const params = { command: "grafana-api.sh datasources" }; - for (let index = 0; index < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; index += 1) { + for (let index = 0; index < CRITICAL_THRESHOLD; index += 1) { recordSuccessfulCall( state, "exec", @@ -560,7 +566,7 @@ describe("tool-loop-detection", () => { expect(loopResult.stuck).toBe(true); if (loopResult.stuck) { expect(loopResult.level).toBe("critical"); - expect(loopResult.detector).toBe("global_circuit_breaker"); + expect(loopResult.detector).toBe("generic_repeat"); } }); @@ -568,7 +574,7 @@ describe("tool-loop-detection", () => { const state = createState(); const params = { command: "tail -f /var/log/app.log", yieldMs: 1000 }; - for (let index = 0; index < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; index += 1) { + for (let index = 0; index < CRITICAL_THRESHOLD; index += 1) { recordSuccessfulCall( state, "exec", @@ -597,7 +603,7 @@ describe("tool-loop-detection", () => { expect(loopResult.stuck).toBe(true); if (loopResult.stuck) { expect(loopResult.level).toBe("critical"); - expect(loopResult.detector).toBe("global_circuit_breaker"); + expect(loopResult.detector).toBe("generic_repeat"); } }); diff --git a/src/agents/tool-loop-detection.ts b/src/agents/tool-loop-detection.ts index 246cefce1c9b..9240bafad3a4 100644 --- a/src/agents/tool-loop-detection.ts +++ b/src/agents/tool-loop-detection.ts @@ -609,11 +609,28 @@ export function detectToolCallLoop( }; } - // Generic detector: warn-only for repeated identical calls. + // Generic detector: warn on repeated identical calls, then block only after + // outcomes prove the calls are not making progress. const recentCount = history.filter( (h) => h.toolName === toolName && h.argsHash === currentHash, ).length; + if ( + !knownPollTool && + resolvedConfig.detectors.genericRepeat && + noProgressStreak >= resolvedConfig.criticalThreshold + ) { + log.error(`Critical generic loop detected: ${toolName} repeated ${noProgressStreak} times`); + return { + stuck: true, + level: "critical", + detector: "generic_repeat", + count: noProgressStreak, + message: `CRITICAL: Called ${toolName} with identical arguments and identical outcomes ${noProgressStreak} times. Session execution blocked to prevent runaway loops.`, + warningKey: `generic:${toolName}:${currentHash}:${noProgress.latestResultHash ?? "none"}`, + }; + } + if ( !knownPollTool && resolvedConfig.detectors.genericRepeat &&