mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user