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:
-
browser.click(ref="e42")→ success, but element moved - Agent retries with
ref="e17"→ also success, also wrong element - Agent reads the URL bar, concludes "login page", tries again
- 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()
Two things to notice:
-
Fingerprinting ignores timestamps so the same
browser.clickcall with a freshnow()doesn't look unique. -
KILL_AFTER = 3means 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" }
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_signaltool the agent must call when it learns something new — if it's been 5 minutes since the lastprogress_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 vialightContext: 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: trueis 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)