Skip to content

Bug: loopDetection cannot block message tool loops — volatile messageId in result defeats all critical-level detection paths #89090

Description

@wujiaming88

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

  1. Configure an agent with DeepSeek-v4-pro (or any model prone to tool-call loops)
  2. Trigger a task that results in a message.send tool call
  3. Model enters a loop, calling message.send with identical arguments repeatedly
  4. Observe: ping_pong detector fires at warningThreshold with action=warn
  5. Observe: even at criticalThreshold and globalCircuitBreakerThreshold, action remains warn — never escalates to block
  6. 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 resultHashstreak = 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 resultHashnoProgressEvidence = 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions