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: 7fa287cd0f
This commit is contained in:
Frank Yang
2026-05-12 00:29:10 +08:00
committed by GitHub
parent af0b775274
commit 678b2510b2
5 changed files with 53 additions and 34 deletions
+1
View File
@@ -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
+2 -2
View File
@@ -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. |
@@ -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 () => {
+12 -6
View File
@@ -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");
}
});
+18 -1
View File
@@ -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 &&