How ML Intern's Doom Loop Detection Stops AI Agents From Spinning — And How You Can Use It

TL;DR: HuggingFace’s ml-intern project includes a 200-line doom_loop.py module that detects when AI agents get stuck repeating the same tool calls. It uses two algorithms — identical consecutive detection and repeating sequence detection — then injects a corrective [SYSTEM: REPETITION GUARD] prompt to break the cycle. We adapted it into a cron watchdog scanning 86+ cron job outputs across our blog empire. The core implementation is hash-based, dependency-free, and fits in ~50 lines of production code.


What Is a Doom Loop?

A doom loop is when an AI agent makes the same tool call — with the exact same arguments — over and over, each time getting the same result. The agent thinks it’s making progress, but it’s burning API tokens in a tight circle.

Real example from our cron pipeline: an auto-fix loop tries to fix a lint error, calls write_file with identical content 5 times, each time the linter returns the same error, and the agent escalates from “fix lint error” to “rewrite entire module” to “delete and recreate” — all without changing the root cause.

The key distinction: legitimate polling (same tool, same args, changing result) is not a doom loop. Only identical args + identical result repeated 3+ times triggers detection.

How HuggingFace ml-intern Solved It

The doom_loop.py module at agent/core/doom_loop.py (6.7 KB) in ml-intern implements two detection strategies on a hash-based call signature [1].

The Signature System

Every tool call gets a frozen, hashable fingerprint:

from dataclasses import dataclass
import json
import hashlib

@dataclass(frozen=True)
class ToolCallSignature:
    """Hashable signature for a single tool call plus its observed result."""
    name: str
    args_hash: str
    result_hash: str | None = None

def _normalize_args(args_str: str) -> str:
    """Canonicalize JSON args before hashing so key order doesn't matter."""
    if not args_str:
        return ""
    try:
        return json.dumps(json.loads(args_str), sort_keys=True, separators=(",", ":"))
    except (json.JSONDecodeError, TypeError, ValueError):
        return args_str

def _hash_args(args_str: str) -> str:
    """Short MD5 of the normalized arguments."""
    return hashlib.md5(_normalize_args(args_str).encode()).hexdigest()[:12]

The normalization is critical — {"model":"gpt-4","temperature":0.7} and {"temperature":0.7,"model":"gpt-4"} hash identically, which is the correct behavior for loop detection.

Algorithm 1: Identical Consecutive Detection

Scans the last N messages (default 30) for two or more of the same signature in a row:

def detect_identical_consecutive(
    signatures: list[ToolCallSignature], threshold: int = 3
) -> str | None:
    """Return the tool name if threshold+ identical consecutive calls exist."""
    count = 0
    prev: ToolCallSignature | None = None
    for sig in signatures:
        if prev is not None and sig == prev:
            count += 1
        else:
            count = 0
        prev = sig
        if count >= threshold:
            return sig.name
    return None

When triggered, it injects: “[SYSTEM: REPETITION GUARD] You have called ‘{tool_name}’ with the same arguments multiple times and gotten the same result. STOP repeating this approach — it is not working.”

Algorithm 2: Repeating Sequence Detection

Some agents don’t repeat the same call — they oscillate between two or three calls in a pattern (e.g., [write_file, read_file, write_file, read_file]). This catches that:

def detect_repeating_sequence(
    signatures: list[ToolCallSignature],
) -> list[ToolCallSignature] | None:
    """Detect patterns of length 2–5 that repeat 2+ times at the tail."""
    n = len(signatures)
    for pattern_len in range(2, min(6, n // 2 + 1)):
        tail = signatures[-pattern_len:]
        prev = signatures[-2 * pattern_len:-pattern_len]
        if tail == prev:
            return tail
    return None

This catches the worst pattern: an agent ping-ponging between two dead-end approaches, each producing the same result.

The Auto-Fix Loop: When Loops Become Doom Loops

Not all repetition is bad. An agent polling a job status endpoint should call the same tool with the same args — but the result changes. That’s why the signature includes result_hash. If the result changes between calls, the signatures don’t match, and no loop is detected.

The dangerous pattern is an auto-fix loop that escalates:

Step 1: write_file(file.py, content_v1) → lint error E201
Step 2: write_file(file.py, content_v2) → lint error E201 (same!)
Step 3: write_file(file.py, content_v3) → lint error E201 (still same!)
Step 4: os.remove(file.py) → error: file in use
Step 5: os.system("rm -rf project/") → 🔴

Each step feels like progress to the agent — it tried something different — but the lint error hasn’t changed. The doom loop detector catches steps 1-3 (identical args? no, different content. But the result is identical). This is where the full ToolCallSignature with result_hash shines.

Our Cron Watchdog Version

We adapted this pattern as ~/.hermes/scripts/doom-loop-guard.py — a no_agent cron that scans all 86 cron job outputs for identical consecutive responses or [SILENT] bailout patterns across our 6-blog empire [2].

The key additions beyond ml-intern’s design:

  1. Content fingerprinting — Strips timestamps and run metadata before hashing, so a job that reports the same thing but with a different timestamp is still caught
  2. SILENT detection — Identical [SINGLE] responses 3x in a row signals topic exhaustion, not a tool loop
  3. Differential reporting — Only reports new findings (previously flagged jobs are skipped until a new pattern emerges)
  4. no_agent job exclusion — Deterministic watchdog scripts are excluded automatically
def content_fingerprint(text: str) -> str:
    """Hash content ignoring timestamps and run metadata."""
    cleaned = re.sub(r'(Run Time:|Job ID:|Next Run:|^\*\*.*?\*\*)', '', text)
    cleaned = re.sub(r'\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}', '', cleaned)
    cleaned = re.sub(r'\s+', ' ', cleaned).strip()
    return hashlib.md5(cleaned.encode()).hexdigest()[:16]

Practical Implementation: ~50 Lines, No External Dependencies

You don’t need ml-intern’s full 6.7 KB module. The core algorithm fits in ~50 lines of pure Python:

import hashlib
import json
from collections import defaultdict
from dataclasses import dataclass

@dataclass(frozen=True)
class ToolCall:
    name: str
    args_hash: str

def hash_args(args: dict) -> str:
    return hashlib.md5(
        json.dumps(args, sort_keys=True).encode()
    ).hexdigest()[:12]

def detect_doom_loop(calls: list[ToolCall], threshold: int = 3) -> str | None:
    """Returns tool name if stuck, None otherwise."""
    count, prev = 0, None
    for call in calls:
        if prev and call == prev:
            count += 1
        else:
            count = 0
        prev = call
        if count >= threshold:
            return call.name
    return None

def detect_oscillation(calls: list[ToolCall]) -> bool:
    """Detect [A, B, A, B] repeating patterns."""
    n = len(calls)
    for plen in range(2, min(6, n // 2 + 1)):
        if calls[-plen:] == calls[-2*plen:-plen]:
            return True
    return False

Drop this into any agent loop. When detect_doom_loop or detect_oscillation fires, inject a corrective prompt into the conversation — no external dependencies, no API calls, no infrastructure.

Why This Matters in Production

In a 2025 survey by LangChain, 57% of organizations reported AI agents in production, but 48% skip offline evaluation and 63% skip production monitoring [3] source. Doom loops are invisible unless you instrument for them — they don’t crash, they just silently waste money.

The ml-intern approach is elegant because it’s in-loop: it runs inside the agent’s own message history, not as an external monitor. The moment a loop pattern emerges, the corrective prompt redirects the agent before it burns more tokens. Our cron watchdog complements this by catching jobs that escape the in-loop detector or run in no_agent mode.

Both are hash-based, pure-Python, and cost nothing to run. If you have AI agents in production — or cron-based agent pipelines — this is the cheapest monitoring you can add.


[1] HuggingFace ml-intern doom_loop.py source: https://github.com/huggingface/ml-intern/blob/main/agent/core/doom_loop.py

[2] Our doom-loop-guard.py: see ~/.hermes/scripts/doom-loop-guard.py in the blog-empire repository

[3] LangChain State of AI Agents 2025: https://www.langchain.com/blog/state-of-ai-agents-2025

← Back to all posts