Bug: loopDetection cannot block message tool loops — volatile messageId in result defeats all critical-level detection paths
Summary
loopDetection correctly detects repeated message.send tool calls via the ping_pong detector (warning level), but can never block them (critical level), because every message.send result contains a unique messageId that produces a different resultHash each time. All critical-level code paths require either noProgressStreak > threshold or noProgressEvidence === true, both of which depend on consecutive identical resultHash — a condition that is structurally impossible for the message tool.
Result: the model receives a text warning ("stop retrying") which it ignores, and continues sending duplicate messages indefinitely until runRetries.max (default 160) is exhausted.
Environment
- OpenClaw 2026.5.7 (confirmed), also verified in 5.28 source
- Model:
deepseek-v4-pro via openai-completions API
- Channel: Feishu
Reproduction
- Configure an agent with DeepSeek-v4-pro (or any model prone to tool-call loops)
- Trigger a task that results in a
message.send tool call
- Model enters a loop, calling
message.send with identical arguments repeatedly
- Observe:
ping_pong detector fires at warningThreshold with action=warn
- Observe: even at
criticalThreshold and globalCircuitBreakerThreshold, action remains warn — never escalates to block
- User receives 30+ duplicate messages
Root Cause Analysis
Each message.send returns a unique messageId:
{"ok": true, "messageId": "om_x100b6ee0a7ddd89cb2914e8ae80041f", "chatId": "oc_..."}
{"ok": true, "messageId": "om_x100b6ee0a712dc90b211e7ee9e3bc93", "chatId": "oc_..."}
{"ok": true, "messageId": "om_x100b6ee0a458f974b2861a436b638eb", "chatId": "oc_..."}
This causes resultHash to differ on every invocation, which defeats three independent detection paths:
Path 1: globalCircuitBreaker (line 286 in 5.28)
if (noProgressStreak >= resolvedConfig.globalCircuitBreakerThreshold)
getNoProgressStreak() requires consecutive calls with same toolName + same argsHash + same resultHash. Different resultHash → streak = 1 always → never triggers.
Path 2: ping_pong critical (line 320 in 5.28)
if (pingPong.count >= resolvedConfig.criticalThreshold && pingPong.noProgressEvidence)
noProgressEvidence requires all results for the same argsHash to have identical resultHash. Different messageId → different resultHash → noProgressEvidence = false → never reaches critical.
Path 3: genericRepeat critical (line 348 in 5.28)
if (noProgressStreak >= resolvedConfig.criticalThreshold)
Same dependency on noProgressStreak → same structural bypass.
Why ping_pong warning fires but is useless
if (pingPong.count >= resolvedConfig.warningThreshold) // no noProgressEvidence check
This path only checks count (based on argsHash pattern matching), so it detects the loop and fires. But warning-level only injects advisory text into the prompt — models like DeepSeek-v4-pro that are already in a loop ignore it completely.
Evidence (logs from production)
tool loop: tool=message level=warning action=warn detector=ping_pong count=11
tool loop: tool=message level=warning action=warn detector=ping_pong count=21
tool loop: tool=message level=warning action=warn detector=ping_pong count=31
Note: all three are action=warn, none escalate to action=block, even though count=31 exceeds globalCircuitBreakerThreshold=30.
Prior Art
This is the same class of bug as #34574 (exec tool), where volatile fields (durationMs, pid, cwd) in hashToolOutcome defeated loop detection. That was fixed in PR #52126 by stripping volatile fields for exec. The message tool needs the same treatment.
Proposed Fix
Option A (targeted): Strip messageId (and any other per-invocation unique fields) from the result before hashing in hashToolOutcome for the message tool. Keep only stable outcome indicators (ok, chatId, isError).
Option B (structural): Add an input-only repeat detector that triggers at critical level when the same tool is called with identical arguments N consecutive times, regardless of result content. This would protect against any tool that returns volatile fields.
Option C (defense in depth): When ping_pong detector fires at count >= criticalThreshold for a tool known to have side effects (like message), escalate to critical even without noProgressEvidence. The "no progress evidence" check makes sense for read-only tools (where different results mean genuine progress), but for write/send tools, same-input repetition is always pathological.
Workaround
Set agents.defaults.runRetries.max to a low value (e.g., 30) to limit total loop iterations. This is currently the only mechanism that can hard-stop this loop on both 5.7 and 5.28.
Impact
- User receives N duplicate messages (observed: 31+)
- Token/cost waste (each iteration burns model tokens)
- Poor UX in production chat channels
- Affects any model with tool-call loop tendencies (DeepSeek V3/V4, Kimi, Qwen in certain contexts)
Bug: loopDetection cannot block
messagetool loops — volatilemessageIdin result defeats all critical-level detection pathsSummary
loopDetectioncorrectly detects repeatedmessage.sendtool calls via theping_pongdetector (warning level), but can never block them (critical level), because everymessage.sendresult contains a uniquemessageIdthat produces a differentresultHasheach time. All critical-level code paths require eithernoProgressStreak > thresholdornoProgressEvidence === true, both of which depend on consecutive identicalresultHash— a condition that is structurally impossible for themessagetool.Result: the model receives a text warning ("stop retrying") which it ignores, and continues sending duplicate messages indefinitely until
runRetries.max(default 160) is exhausted.Environment
deepseek-v4-proviaopenai-completionsAPIReproduction
message.sendtool callmessage.sendwith identical arguments repeatedlyping_pongdetector fires atwarningThresholdwithaction=warncriticalThresholdandglobalCircuitBreakerThreshold, action remainswarn— never escalates toblockRoot Cause Analysis
Each
message.sendreturns a uniquemessageId:{"ok": true, "messageId": "om_x100b6ee0a7ddd89cb2914e8ae80041f", "chatId": "oc_..."} {"ok": true, "messageId": "om_x100b6ee0a712dc90b211e7ee9e3bc93", "chatId": "oc_..."} {"ok": true, "messageId": "om_x100b6ee0a458f974b2861a436b638eb", "chatId": "oc_..."}This causes
resultHashto differ on every invocation, which defeats three independent detection paths:Path 1:
globalCircuitBreaker(line 286 in 5.28)getNoProgressStreak()requires consecutive calls with same toolName + same argsHash + same resultHash. DifferentresultHash→streak = 1always → never triggers.Path 2:
ping_pongcritical (line 320 in 5.28)noProgressEvidencerequires all results for the sameargsHashto have identicalresultHash. DifferentmessageId→ differentresultHash→noProgressEvidence = false→ never reaches critical.Path 3:
genericRepeatcritical (line 348 in 5.28)Same dependency on
noProgressStreak→ same structural bypass.Why
ping_pongwarning fires but is uselessThis path only checks
count(based onargsHashpattern matching), so it detects the loop and fires. But warning-level only injects advisory text into the prompt — models like DeepSeek-v4-pro that are already in a loop ignore it completely.Evidence (logs from production)
Note: all three are
action=warn, none escalate toaction=block, even though count=31 exceedsglobalCircuitBreakerThreshold=30.Prior Art
This is the same class of bug as #34574 (exec tool), where volatile fields (
durationMs,pid,cwd) inhashToolOutcomedefeated loop detection. That was fixed in PR #52126 by stripping volatile fields for exec. Themessagetool needs the same treatment.Proposed Fix
Option A (targeted): Strip
messageId(and any other per-invocation unique fields) from the result before hashing inhashToolOutcomefor themessagetool. Keep only stable outcome indicators (ok,chatId,isError).Option B (structural): Add an input-only repeat detector that triggers at critical level when the same tool is called with identical arguments N consecutive times, regardless of result content. This would protect against any tool that returns volatile fields.
Option C (defense in depth): When
ping_pongdetector fires atcount >= criticalThresholdfor a tool known to have side effects (likemessage), escalate to critical even withoutnoProgressEvidence. The "no progress evidence" check makes sense for read-only tools (where different results mean genuine progress), but for write/send tools, same-input repetition is always pathological.Workaround
Set
agents.defaults.runRetries.maxto a low value (e.g., 30) to limit total loop iterations. This is currently the only mechanism that can hard-stop this loop on both 5.7 and 5.28.Impact