Skip to content

Commit 9fd4b85

Browse files
committed
fix(loop-detection): block generic repeats with no-progress evidence
1 parent 6d782ad commit 9fd4b85

4 files changed

Lines changed: 35 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212

1313
### Fixes
1414

15+
- Agents/loop detection: require no-progress evidence before generic repeated tool calls escalate from warning to critical blocking, so legitimate repeated calls with changing output can continue. Fixes #54559; refs #60111, #60248, and #70546. Thanks @jwchmodx.
1516
- Control UI/WebChat: keep large attachment payloads out of Lit state and optimistic chat messages, using object URL previews plus send-time payload serialization so PDF/image uploads no longer trigger `RangeError: Maximum call stack size exceeded`. Fixes #73360; refs #54378 and #63432. Thanks @hejunhui-73, @Ansub, and @christianhernandez3-afk.
1617
- Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto.
1718
- Gateway/models: add `models.pricing.enabled` so offline or restricted-network installs can skip startup OpenRouter and LiteLLM pricing-catalog fetches while keeping explicit model costs working. Fixes #53639. Thanks @callebtc, @palewire, and @rjdjohnston.

docs/tools/loop-detection.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ Per-agent override (optional):
6767
- `enabled`: Master switch. `false` means no loop detection is performed.
6868
- `historySize`: number of recent tool calls kept for analysis.
6969
- `warningThreshold`: threshold before classifying a pattern as warning-only.
70-
- `criticalThreshold`: threshold for blocking repetitive loop patterns.
70+
- `criticalThreshold`: threshold for blocking repetitive loop patterns once no-progress evidence is present.
7171
- `globalCircuitBreakerThreshold`: global no-progress breaker threshold.
72-
- `detectors.genericRepeat`: detects repeated same-tool + same-params patterns.
72+
- `detectors.genericRepeat`: detects repeated same-tool + same-params patterns. It warns on repeated arguments and escalates to critical only when the repeated calls also produce identical outcomes.
7373
- `detectors.knownPollNoProgress`: detects known polling-like patterns with no state change.
7474
- `detectors.pingPong`: detects alternating ping-pong patterns.
7575

src/agents/tool-loop-detection.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,32 @@ describe("tool-loop-detection", () => {
396396
}
397397
});
398398

399+
it("does not escalate generic repeats to critical when outcomes are progressing", () => {
400+
const state = createState();
401+
const params = { path: "/same.txt" };
402+
403+
for (let index = 0; index < CRITICAL_THRESHOLD; index += 1) {
404+
recordSuccessfulCall(
405+
state,
406+
"read",
407+
params,
408+
{
409+
content: [{ type: "text", text: `changed output ${index}` }],
410+
details: { ok: true },
411+
},
412+
index,
413+
);
414+
}
415+
416+
const loopResult = detectToolCallLoop(state, "read", params, enabledLoopDetectionConfig);
417+
expect(loopResult.stuck).toBe(true);
418+
if (loopResult.stuck) {
419+
expect(loopResult.level).toBe("warning");
420+
expect(loopResult.detector).toBe("generic_repeat");
421+
expect(loopResult.count).toBe(CRITICAL_THRESHOLD);
422+
}
423+
});
424+
399425
it("applies custom thresholds when detection is enabled", () => {
400426
const state = createState();
401427
const { params, result } = createNoProgressPollFixture("sess-custom");

src/agents/tool-loop-detection.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -609,26 +609,26 @@ export function detectToolCallLoop(
609609
};
610610
}
611611

612-
// Generic detector: warn then block for repeated identical calls.
612+
// Generic detector: warn on repeated identical calls, but block only with no-progress evidence.
613613
const recentCount = history.filter(
614614
(h) => h.toolName === toolName && h.argsHash === currentHash,
615615
).length;
616616

617617
if (
618618
!knownPollTool &&
619619
resolvedConfig.detectors.genericRepeat &&
620-
recentCount >= resolvedConfig.criticalThreshold
620+
noProgressStreak >= resolvedConfig.criticalThreshold
621621
) {
622622
log.error(
623-
`Critical loop detected: ${toolName} called ${recentCount} times with identical arguments`,
623+
`Critical loop detected: ${toolName} repeated ${noProgressStreak} times with no progress`,
624624
);
625625
return {
626626
stuck: true,
627627
level: "critical",
628628
detector: "generic_repeat",
629-
count: recentCount,
630-
message: `CRITICAL: You have called ${toolName} ${recentCount} times with identical arguments and are making no progress. Session execution blocked to prevent runaway loops.`,
631-
warningKey: `generic:${toolName}:${currentHash}`,
629+
count: noProgressStreak,
630+
message: `CRITICAL: You have called ${toolName} ${noProgressStreak} times with identical arguments and identical outcomes. Session execution blocked to prevent runaway loops.`,
631+
warningKey: `generic:${toolName}:${currentHash}:${noProgress.latestResultHash ?? "none"}`,
632632
};
633633
}
634634

0 commit comments

Comments
 (0)