DEV Community

MrClaw207
MrClaw207

Posted on

I Built an Anti-Thrash Watchdog Sidecar for My OpenClaw Agent. It Killed 3 Loops Before I Even Noticed

I Built an Anti-Thrash Watchdog Sidecar for My OpenClaw Agent. It Killed 3 Loops Before I Even Noticed

Last Tuesday my OpenClaw agent burned 11,000 tokens in 90 seconds re-trying the same browser action. By the time I saw the Telegram alert, the loop had already restarted twice. That's the day I built a watchdog sidecar — and in the first week it killed three loops I never would have caught manually.

If you've ever looked at your token bill at the end of the day and thought "where did all of this go?", there's a good chance the answer is agent thrashing: the same tool call failing, retrying, partially succeeding, and re-firing. A watchdog that watches for the shape of thrash (not just errors) is more useful than any retry-budget config I've tried.

Why error budgets don't catch this

OpenClaw has a maxRetries setting. It helps. But thrashing is rarely failing in the strict sense — each call returns a 200, the agent just isn't making progress. The classic pattern looks like this:

  1. browser.click(ref="e42") → success, but element moved
  2. Agent retries with ref="e17" → also success, also wrong element
  3. Agent reads the URL bar, concludes "login page", tries again
  4. 11 turns later, still on the login page

No error budget triggers. The agent is succeeding its way into a hole. You need a different signal.

The signal I went with: tool-call locality

I instrumented the gateway with a small middleware that logs every tool call to a JSONL file with agentId, tool, args_hash, result_hash, and timestamp. The watchdog reads the last 20 calls and asks a simple question:

How many of the last 10 tool calls are within edit-distance 3 of a call that happened in the last 60 seconds?

If the answer is ≥ 6, the agent is looping. Here's the meat of it:

# scripts/thrash-watchdog.py — runs as a cron every 60s
import json, hashlib, time, subprocess
from pathlib import Path

LOG = Path("/home/themachine/.openclaw/workspace/data/gateway-tools.jsonl")
STATE = Path("/tmp/thrash-state.json")
THRESHOLD = 6          # matching calls in last 10
WINDOW = 60            # seconds
KILL_AFTER = 3         # consecutive thrash detections before killing

def fingerprint(call):
    # Hash tool + canonical args, ignore volatile fields like timestamp
    return hashlib.sha1(
        json.dumps(call["tool"]) +
        json.dumps(call["args"], sort_keys=True, default=str)
        .replace(str(call["args"].get("timestamp", "")), "")
        .encode()
    ).hexdigest()[:12]

def detect():
    if not LOG.exists():
        return False, 0
    cutoff = time.time() - WINDOW
    calls = []
    with LOG.open() as f:
        for line in f:
            try:
                calls.append(json.loads(line))
            except json.JSONDecodeError:
                continue
    recent = [c for c in calls if c["ts"] > cutoff]
    fps = [fingerprint(c) for c in recent[-10:]]
    if len(fps) < 6:
        return False, 0
    # count fingerprints that appear more than once
    duplicates = sum(1 for fp in fps if fps.count(fp) > 1)
    return duplicates >= THRESHOLD, duplicates

def main():
    thrashing, score = detect()
    state = {"count": 0}
    if STATE.exists():
        state = json.loads(STATE.read_text())
    if thrashing:
        state["count"] += 1
        print(f"[watchdog] thrash score={score} streak={state['count']}")
        if state["count"] >= KILL_AFTER:
            subprocess.run([
                "openclaw", "agent", "kill",
                "--reason", f"thrash_loop:{score}",
                "--preserve-state"
            ], check=False)
            state["count"] = 0
    else:
        state["count"] = 0
    STATE.write_text(json.dumps(state))

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Two things to notice:

  1. Fingerprinting ignores timestamps so the same browser.click call with a fresh now() doesn't look unique.
  2. KILL_AFTER = 3 means one weird 60-second window doesn't kill a legitimate long task. Three consecutive windows of thrashing does.

Wiring it into cron

I run it as a foreground cron every minute. The wrapper is one line:

# openclaw.json — cron section (abbreviated)
- name: "Thrash watchdog"
  schedule: { kind: "every", everyMs: 60000 }
  payload:
    kind: "agentTurn"
    message: "exec:python3 /home/themachine/.openclaw/workspace/scripts/thrash-watchdog.py"
    lightContext: true
    timeoutSeconds: 30
  sessionTarget: isolated
  delivery: { mode: "none" }
Enter fullscreen mode Exit fullscreen mode

lightContext: true matters — the watchdog doesn't need my full workspace state, just the script. Each run is ~150 tokens.

The three loops it actually killed

Here's what the watchdog caught in week one:

When Tool Loop Damage without kill
Tue 14:03 browser.click 9 retries on a CAPTCHA iframe ~8k tokens, 3 min
Wed 09:41 shell.exec npm test failing the same way 4 times ~3k tokens, 90s
Fri 22:17 sessions_spawn spawning the same sub-agent by mistake ~12k tokens, 5 min

The Friday one was the worst. My self-improvement cron had a typo that caused it to spawn a research sub-agent, which spawned another research sub-agent, which... you get it. The watchdog killed the whole chain at minute 5. Without it, I would have woken up to a $14 bill.

What it doesn't catch

Honest list of failures:

  • Slow loops. A loop that takes 4 minutes per iteration looks like progress to the locality check. I'm experimenting with adding a progress_signal tool the agent must call when it learns something new — if it's been 5 minutes since the last progress_signal, kill.
  • Wrong-tool loops. When the agent switches tools but isn't converging on a goal, my fingerprint misses it. Future work.
  • Sub-agent loops inside sessions_spawn. The Friday incident got caught only because the outer agent's call log reflected the spawn. If the sub-agent runs entirely in its own context, the parent watchdog doesn't see it. Workaround: each sub-agent inherits the same watchdog via lightContext: true.

What I learned

Three weeks in, here's what I'd tell anyone setting this up:

  • Locality beats error count. Errors are a lagging indicator. Tool-call repetition is leading.
  • 60 seconds is the right window. Shorter and you false-positive on legitimate retries. Longer and the loop has already cost you.
  • lightContext: true is non-negotiable. A watchdog that costs 2k tokens per check defeats the purpose.

The whole sidecar is ~80 lines of Python and one cron entry. If you're running OpenClaw on any budget that isn't "infinite", I'd build it before you build anything else. The first loop it kills pays for the engineering.

If you've built something similar — different signal, different kill policy — I'd love to hear what worked. Especially curious if anyone has a clean way to detect goal divergence instead of just call repetition.

Top comments (0)