PromptisePromptise
Docs
GitHub
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python

← Guides> AI Engineering

AI Agent Stuck in a Loop? Detect and Stop It

Why an AI agent gets stuck in a loop, measured over 240 real runs: detect repeated tool calls in Python, stop them mid-run, and fix the tool results.

Level
Intermediate
Reading time
14 min
Published
Oct 15, 2026
By
Promptise Team
  • AI Agents
  • Agent Loops
  • Reliability
  • Cost Control
  • Observability
  • Python
  • Promptise Foundry

An AI agent stuck in a loop keeps calling tools without getting closer to an answer: it polls a job that never finishes, bounces between two lookups that point at each other, or rephrases a search that keeps coming back empty. Nothing crashes. The run just burns tokens until a limit ends it, often with no answer at all. This guide reproduces all three loops with a real model, detects them with Promptise Foundry's HealthMonitor, stops them mid-run, and measures what each fix is worth. Over 240 runs of gpt-5-mini, gpt-5-nano and gpt-4.1-mini, a loop guard cut the polling loop from 15 tool calls to 3 and got an answer in 10 of 10 runs instead of 0. It never caught the other two loops, because the agent changed its arguments on every call. A hard cap on tool calls caught all three. The fix that worked best was not a guard at all: tool results that tell the agent what to do next. Loops are also a cost problem, so this is a companion to AI agent cost control. Everything ran on Promptise Foundry 1.3.3 from PyPI, and every output below is pasted from those runs.

[01]

Why do AI agents get stuck in loops?

A tool-calling agent runs a loop by design: the model asks for a tool, reads the result, and decides whether to call another tool or answer. It gets stuck when no tool result ever gives it a reason to stop. Three patterns cover most cases:

  • Identical calls. The agent calls the same tool with the same arguments again and again. The classic case is polling: "the export is pending", so it checks again.

  • Ping-pong. Two tools send the agent to each other. get_account says "not found, try find_account", and find_account says "several matches, use get_account".

  • No-op results. Every call succeeds and returns nothing useful, such as an empty search. The agent keeps rephrasing, so no two calls look the same.

The system prompt matters as much as the tools. In our smoke tests a polite "You are an operations assistant" prompt made gpt-5-mini give up early and ask the user. An autonomous prompt ("Nobody is watching this run, so don't ask questions") made it loop. Production agents that run on schedules or webhooks get the second kind of prompt, so that's the one we test.


[02]

What you need

  • Python 3.10 or newer and Promptise Foundry 1.3.3 or newer.

  • An OpenAI API key in OPENAI_API_KEY. The examples use openai:gpt-5-mini.

  • About $1 for the full 240-run experiment at OpenAI's list prices. A single run costs well under a cent.

>_Terminal
pip install "promptise>=1.3.3"

[03]

Step 1: Build three tools that trap an agent

The MCP server below has one tool set per loop type. Each tool exists in two versions, chosen by an environment variable. vague returns what many real APIs return. clear returns the same facts plus what the agent should do next, which we test in step 6.

Pythonloop_server.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
"""Three tools that trap agents in loops, in two versions.

TOOL_MESSAGES=vague  -> what many real APIs return
TOOL_MESSAGES=clear  -> the same facts, plus what the agent should do next
"""
import os

from promptise.mcp.server import MCPServer, ToolError

CLEAR = os.environ.get("TOOL_MESSAGES", "vague") == "clear"
server = MCPServer("loops")


# 1. Identical calls: a job that never finishes while the agent watches.
@server.tool()
async def start_export(report: str) -> dict:
    """Start exporting a report. Returns an export ID."""
    return {"export_id": "EX-7731", "status": "pending"}


@server.tool(read_only_hint=True)
async def get_export_status(export_id: str) -> dict:
    """Get the status of an export and its download link when it is ready."""
    if CLEAR:
        return {"export_id": export_id, "status": "pending",
                "next_step": "Exports take about 20 minutes. Checking again now will not "
                             "change the status. Tell the user the export ID; the link is "
                             "emailed to them when it is ready."}
    return {"export_id": export_id, "status": "pending"}


# 2. Ping-pong: two tools that send the agent to each other.
@server.tool(read_only_hint=True)
async def get_account(email: str) -> dict:
    """Get a customer account by email address."""
    if CLEAR:
        raise ToolError(f"No account has the email {email}. Lookups are exact, so retrying "
                        "or searching by name will not find it.",
                        suggestion="Ask the user to confirm the email address.")
    raise ToolError("Account not found.", suggestion="Try find_account.")


@server.tool(read_only_hint=True)
async def find_account(name: str) -> dict:
    """Find customer accounts by name."""
    if CLEAR:
        return {"matches": 2, "accounts": ["jo.smith@acme.io", "jo.smith@acme.com"],
                "next_step": "Two accounts match. Ask the user which email is theirs."}
    raise ToolError("Several accounts match.", suggestion="Use get_account with the exact email.")


# 3. No-op results: a search that keeps coming back empty.
@server.tool(read_only_hint=True)
async def search_docs(query: str) -> dict:
    """Search the internal policy documents."""
    if CLEAR:
        return {"results": [], "next_step": "The policy index has nothing on this topic, so "
                "other wordings will return nothing too. Tell the user it isn't documented."}
    return {"results": []}


if __name__ == "__main__":
    server.run(transport="stdio")

[04]

Step 2: Watch an agent loop

watch_one.py gives the polling task to a Promptise agent once and prints every tool call from the observability collector. Run it without arguments first.

Pythonwatch_one.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
"""Run the polling task once and print every tool call the agent makes.

python watch_one.py            # no guard
python watch_one.py --guard    # with LoopGuard
python watch_one.py --budget   # with a hard cap of 8 tool calls per run
"""
import asyncio
import sys

from loop_guard import LoopGuard
from promptise import build_agent
from promptise.config import StdioServerSpec
from promptise.runtime import BudgetConfig, BudgetState
from promptise.runtime.callbacks import RuntimeCallbackHandler


async def main(mode: str):
    agent = await build_agent(
        servers={"loops": StdioServerSpec(command=sys.executable, args=["loop_server.py"])},
        model="openai:gpt-5-mini",
        instructions="You are an autonomous operations agent. Nobody is watching this run, so "
                     "don't ask questions: keep working with the tools until the task is done.",
        observe=True,
    )
    guard = LoopGuard()
    budget = RuntimeCallbackHandler(
        budget=BudgetState(BudgetConfig(enabled=True, max_tool_calls_per_run=8)))
    callbacks = {"--guard": [guard], "--budget": budget.callbacks()}.get(mode)
    try:
        result = await agent.ainvoke(
            {"messages": [{"role": "user", "content": "Export the Q3 sales report and send me the download link."}]},
            config={"callbacks": callbacks} if callbacks else None,
        )
        print("ANSWER:", result["messages"][-1].content)
    except Exception as exc:
        print("FAILED:", type(exc).__name__, str(exc)[:160])
    events = agent.collector.get_timeline()
    for n, e in enumerate([e for e in events if e.event_type.value == "tool.call"], 1):
        print(f"  {n:>2}. {e.metadata['tool_name']}({e.metadata.get('arguments', '')})")
    end = next(e.metadata for e in events if e.event_type.value in ("agent.output", "agent.error"))
    print(f"model calls: {end.get('llm_call_count')}  tokens: "
          f"{end.get('prompt_tokens', 0) + end.get('completion_tokens', 0)}"
          + (f"  guard refused from call {guard.tripped_at}" if guard.tripped_at else "")
          + (f"  budget: {budget.pending_violations[0].limit_name}" if budget.pending_violations else ""))
    await agent.shutdown()


asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else ""))
Output
$ python watch_one.py 
FAILED: GraphIterationLimitError Graph 'react' failed at node 'reason': reached its iteration limit (15) before the model gave an answer; the run stopped after a call to get_export_status whose
   1. start_export({'report': 'Q3 sales report'})
   2. get_export_status({'export_id': 'EX-7731'})
   3. get_export_status({'export_id': 'EX-7731'})
   4. get_export_status({'export_id': 'EX-7731'})
   5. get_export_status({'export_id': 'EX-7731'})
   6. get_export_status({'export_id': 'EX-7731'})
   7. get_export_status({'export_id': 'EX-7731'})
   8. get_export_status({'export_id': 'EX-7731'})
   9. get_export_status({'export_id': 'EX-7731'})
  10. get_export_status({'export_id': 'EX-7731'})
  11. get_export_status({'export_id': 'EX-7731'})
  12. get_export_status({'export_id': 'EX-7731'})
  13. get_export_status({'export_id': 'EX-7731'})
  14. get_export_status({'export_id': 'EX-7731'})
  15. get_export_status({'export_id': 'EX-7731'})
model calls: 15  tokens: 9088

Fifteen identical status checks, then GraphIterationLimitError. The user gets no answer, not even the export ID the agent had after its first call. The limit that ended the run is not max_agent_iterations (25 by default) but the ReAct reason node's own budget of 15 iterations, as the engine docs describe. That's the only loop protection an agent has by default, and it fires after the agent has spent 9,088 tokens on a question it could have answered after one call.


[05]

Step 3: Detect the loop with HealthMonitor

Promptise's HealthMonitor is a pattern matcher with no LLM calls. You feed it each tool call, and it reports an Anomaly when one of its detectors fires:

  • stuck: the same tool with the same arguments stuck_threshold times in a row (default 3).

  • loop: a repeating sequence of calls, such as A-B-A-B, inside the last loop_window calls (default 20).

  • empty_response and high_error_rate: across replies and runs, not tool calls.

Both tool-call detectors compare the tool name *and* a hash of its arguments, which matters later. In the autonomous runtime you switch it on with HealthConfig(enabled=True, on_anomaly="escalate"), but the health docs say the action runs when the invocation that produced the anomaly ends. A monitor that reports at the end of a run tells you the agent looped. It doesn't save the tokens.


[06]

Step 4: Stop the loop mid-run with a guard

To stop a loop while it happens, check each tool call before it runs. LangChain callbacks run before every tool, and Promptise passes config={"callbacks": [...]} through to them. The guard below feeds each call to a HealthMonitor and refuses the call that completes a pattern, plus every call after it. The refusal becomes the tool's result, so the agent reads why it was stopped and gets a turn to answer.

Pythonloop_guard.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
"""Stop a looping agent mid-run with Promptise's HealthMonitor."""
from langchain_core.callbacks import BaseCallbackHandler
from promptise.runtime import HealthConfig
from promptise.runtime.health import HealthMonitor


class LoopDetected(RuntimeError):
    """Raised before a tool call that would continue a loop."""


def _now(coro):
    # HealthMonitor.record_tool_call is declared async but never awaits
    # anything, so one step runs it to completion.
    try:
        coro.send(None)
    except StopIteration as done:
        return done.value
    coro.close()
    raise RuntimeError("HealthMonitor.record_tool_call suspended")


class LoopGuard(BaseCallbackHandler):
    """Feeds every tool call to a HealthMonitor and refuses the call that
    completes a stuck or looping pattern, plus every call after it.

    Synchronous and inline, like Promptise's BudgetGuard: LangChain runs it
    before the tool executes and lets its exception through. (An exception
    from an async handler is logged and the tool runs anyway.)"""

    raise_error = True
    run_inline = True

    def __init__(self, config: HealthConfig | None = None) -> None:
        self.monitor = HealthMonitor(config or HealthConfig(enabled=True), process_id="guard")
        self.tripped_at: int | None = None
        self.calls = 0

    def on_tool_start(self, serialized, input_str, *, inputs=None, **kwargs):
        self.calls += 1
        name = serialized.get("name", "")
        anomaly = _now(self.monitor.record_tool_call(name, dict(inputs or {})))
        if anomaly is not None or self.tripped_at is not None:
            self.tripped_at = self.tripped_at or self.calls
            raise LoopDetected(
                f"Stopped: {anomaly.description if anomaly else 'loop already detected'}. "
                "Calling tools again will not help. Answer the user now with what you know, "
                "and say what is blocking you."
            )

Two details are not optional:

  • The guard must be a synchronous handler with `run_inline = True` and `raise_error = True`. Our first version was an AsyncCallbackHandler that raised. LangChain logged "Error in LoopGuard.on_tool_start callback" and ran the tool anyway: 15 of 15 calls went through. Promptise's own BudgetGuard is built the synchronous way for the same reason.

  • `HealthMonitor.record_tool_call` is async, and a synchronous handler can't await it. It never awaits anything internally (we checked the source of promptise/runtime/health.py in 1.3.3), so _now() runs it to completion in one step and raises if that ever changes.

You can test the guard without an API key. A scripted model polls four times and then answers:

Pythonfake_guard_seen.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
"""Test LoopGuard without an API key: a scripted model polls four times, then answers.
Prints every tool result the model receives."""
import asyncio
import sys

from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage, ToolMessage
from loop_guard import LoopGuard
from promptise import build_agent
from promptise.config import StdioServerSpec

seen = []


class ScriptedModel(GenericFakeChatModel):
    def bind_tools(self, tools, **kwargs):
        return self

    async def _agenerate(self, messages, *args, **kwargs):
        results = [m for m in messages if isinstance(m, ToolMessage)]
        if results:
            seen.append(str(results[-1].content)[:200])
        return await super()._agenerate(messages, *args, **kwargs)


def script():
    for i in range(4):
        yield AIMessage(content="", tool_calls=[{"name": "get_export_status",
                                                 "args": {"export_id": "EX-7731"}, "id": f"c{i}"}])
    yield AIMessage(content="The export is still pending.")


async def main():
    agent = await build_agent(
        servers={"loops": StdioServerSpec(command=sys.executable, args=["loop_server.py"])},
        model=ScriptedModel(messages=script()),
    )
    result = await agent.ainvoke({"messages": [{"role": "user", "content": "Export Q3."}]},
                                 config={"callbacks": [LoopGuard()]})
    for text in seen:
        print("tool result:", text)
    print("answer:", result["messages"][-1].content)
    await agent.shutdown()


asyncio.run(main())
Output
tool result: {"export_id": "EX-7731", "status": "pending"}
tool result: {"export_id": "EX-7731", "status": "pending"}
tool result: Error: LoopDetected: Stopped: Agent stuck: tool 'get_export_status' called 3 times consecutively with identical arguments. Calling tools again will not help. Answer the user now with what you know, an
tool result: Error: LoopDetected: Stopped: loop already detected. Calling tools again will not help. Answer the user now with what you know, and say what is blocking you.
answer: The export is still pending.

The first two checks ran. The third identical call was refused, and the model saw the refusal as the tool result. LangChain also logs one "Error in LoopGuard.on_tool_start callback" line per refused call to stderr; the tool body doesn't run. With the real model:

Output
$ python watch_one.py --guard
ANSWER: I started the export for "Q3 sales report". Export ID: EX-7731. Current status: pending.

I attempted to poll the export status to get the download link, but the system blocked further status calls (tool loop detection). Because of that I can't retrieve the download URL right now.

Options — tell me which you prefer:
- I continue checking and will provide the download link here as soon as the export becomes ready.
- I start a new export now.
- I notify you by email (tell me the address) when the link is ready.

Which would you like me to do?
   1. start_export({'report': 'Q3 sales report'})
   2. get_export_status({'export_id': 'EX-7731'})
   3. get_export_status({'export_id': 'EX-7731'})
model calls: 5  tokens: 3016  guard refused from call 4

Three calls, an answer with the export ID, and 3,016 tokens instead of 9,088.


[07]

Step 5: Add a hard cap with the autonomy budget

A detector catches patterns it knows. A budget catches everything. Promptise's autonomy budget enforces max_tool_calls_per_run before each call: once the limit is reached the tool body doesn't run, and the agent receives a BudgetExceededError telling it to stop calling tools and answer (budget docs). Outside the autonomous runtime you use it through RuntimeCallbackHandler, exactly as watch_one.py --budget does:

Output
$ python watch_one.py --budget
ANSWER: I've started the export of the Q3 sales report.

- Export ID: EX-7731
- Current status: pending — the export is still being prepared and the download link is not yet available.

I will send you the download link as soon as the export is ready.
   1. start_export({'report': 'Q3 sales report'})
   2. get_export_status({'export_id': 'EX-7731'})
   3. get_export_status({'export_id': 'EX-7731'})
   4. get_export_status({'export_id': 'EX-7731'})
   5. get_export_status({'export_id': 'EX-7731'})
   6. get_export_status({'export_id': 'EX-7731'})
   7. get_export_status({'export_id': 'EX-7731'})
   8. get_export_status({'export_id': 'EX-7731'})
model calls: 10  tokens: 6385  budget: max_tool_calls_per_run

Eight status checks, then the refusal, then an answer. That's 6,385 tokens: more than the guard, because the cap doesn't care whether the calls were useful, but less than the 9,088 the uncapped run burned for nothing. RuntimeCallbackHandler also takes health=HealthMonitor(...), which records anomalies during the run without stopping anything; in our budget runs it flagged every polling loop at the fourth call.


[08]

Step 6: Measure it, 240 runs

run_loops.py runs each of the three tasks 10 times per condition with up to 6 runs in parallel, records every tool call and token count from the collector, and replays each run's tool calls through a fresh HealthMonitor to find where it would have flagged the run. It turns off argument redaction in the collector, because the default redaction rewrites every email address to [EMAIL]. With redaction on, our first pass showed different lookups as identical calls and made the monitor look far better at catching ping-pong than it is.

Pythonrun_loops.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
"""Measure how long agents loop, with and without a guard.

python run_loops.py --messages vague --guard none --trials 10
"""
import argparse
import asyncio
import json
import os
import sys
import time
from pathlib import Path

from loop_guard import LoopGuard
from promptise import build_agent
from promptise.agent import CallerContext
from promptise.config import StdioServerSpec
from promptise.observability_config import ObservabilityConfig
from promptise.runtime import BudgetConfig, BudgetState, HealthConfig
from promptise.runtime.callbacks import RuntimeCallbackHandler
from promptise.runtime.health import HealthMonitor

PROMPTS = {
    "assistant": "You are an operations assistant. Use the tools to help the user.",
    "autonomous": "You are an autonomous operations agent. Nobody is watching this run, so "
                  "don't ask questions: keep working with the tools until the task is done.",
}
SCENARIOS = {
    "polling": "Export the Q3 sales report and send me the download link.",
    "ping_pong": "What plan is the customer jo.smith@acme.io on?",
    "empty_search": "What's our refund policy for annual plans? Check the policy docs.",
}


async def first_anomaly(calls: list[dict]) -> tuple[int, str] | None:
    monitor = HealthMonitor(HealthConfig(enabled=True), process_id="replay")
    for i, call in enumerate(calls, 1):
        anomaly = await monitor.record_tool_call(call["tool"], call["args"])
        if anomaly is not None:
            return i, anomaly.anomaly_type.value
    return None


async def run_one(agent, scenario, trial, guard_kind):
    run_id = f"{scenario}#{trial}"
    guard = LoopGuard() if guard_kind == "health" else None
    budget = monitor = None
    config = {"callbacks": [guard]} if guard else None
    if guard_kind == "budget":
        budget = RuntimeCallbackHandler(
            budget=BudgetState(BudgetConfig(enabled=True, max_tool_calls_per_run=8)),
            health=(monitor := HealthMonitor(HealthConfig(enabled=True), process_id=run_id)),
        )
        config = {"callbacks": budget.callbacks()}
    started = time.perf_counter()
    outcome, answer = "answered", ""
    try:
        result = await agent.ainvoke(
            {"messages": [{"role": "user", "content": SCENARIOS[scenario]}]},
            config=config,
            caller=CallerContext(user_id="loops", metadata={"session_id": run_id}),
        )
        answer = str(result["messages"][-1].content)
    except Exception as exc:
        if "insufficient_quota" in str(exc):  # out of API credit: the run says nothing
            os._exit(3)
        outcome, answer = type(exc).__name__, str(exc)[:300]
    events = agent.collector.query(session_ids=[run_id])
    calls = []
    for e in events:
        if e.event_type.value == "tool.call":
            raw = e.metadata.get("arguments", "{}")
            try:
                import ast
                args = ast.literal_eval(raw)
            except Exception:
                args = {"raw": raw}
            calls.append({"tool": e.metadata["tool_name"], "args": args})
    end = next((e.metadata for e in events if e.event_type.value in ("agent.output", "agent.error")), {})
    found = await first_anomaly(calls)
    return {"scenario": scenario, "trial": trial, "outcome": outcome, "answer": answer,
            "tool_calls": len(calls), "calls": [c["tool"] for c in calls],
            "args": [c["args"] for c in calls],
            "llm_calls": end.get("llm_call_count", 0),
            "prompt_tokens": end.get("prompt_tokens", 0),
            "completion_tokens": end.get("completion_tokens", 0),
            "detected_at": found[0] if found else None, "anomaly": found[1] if found else None,
            "guard_tripped_at": guard.tripped_at if guard else None,
            "budget_violations": [v.limit_name for v in budget.pending_violations] if budget else None,
            "budget_run_anomalies": [a.anomaly_type.value for a in monitor.anomalies] if monitor else None,
            "seconds": round(time.perf_counter() - started, 1)}


async def main():
    p = argparse.ArgumentParser()
    p.add_argument("--messages", choices=["vague", "clear"], default="vague")
    p.add_argument("--guard", choices=["none", "health", "budget"], default="none")
    p.add_argument("--trials", type=int, default=10)
    p.add_argument("--model", default="openai:gpt-5-mini")
    p.add_argument("--prompt", choices=list(PROMPTS), default="autonomous")
    p.add_argument("--only", nargs="*")
    p.add_argument("--max-iterations", type=int, default=25)
    p.add_argument("--out", default="results")
    a = p.parse_args()
    agent = await build_agent(
        servers={"loops": StdioServerSpec(command=sys.executable, args=["loop_server.py"],
                                          env={"TOOL_MESSAGES": a.messages})},
        model=a.model,
        instructions=PROMPTS[a.prompt],
        # Keep real tool arguments: the default redaction turns every email into
        # "[EMAIL]", which makes different lookups look identical on replay.
        observe=ObservabilityConfig(redact_sensitive=False),
        max_agent_iterations=a.max_iterations,
    )
    gate = asyncio.Semaphore(6)

    async def bounded(s, t):
        async with gate:
            return await run_one(agent, s, t, a.guard)

    try:
        runs = await asyncio.gather(*(bounded(s, t) for s in SCENARIOS if not a.only or s in a.only
                                      for t in range(a.trials)))
    finally:
        await agent.shutdown()
    Path(a.out).mkdir(exist_ok=True)
    name = f"{a.model.split(':')[-1]}-{a.prompt}-{a.messages}-{a.guard}"
    if a.max_iterations != 25:
        name += f"-cap{a.max_iterations}"
    (Path(a.out) / f"{name}.json").write_text(json.dumps(runs, indent=1))
    tin = sum(r["prompt_tokens"] for r in runs); tout = sum(r["completion_tokens"] for r in runs)
    print(f"TOKENS in={tin} out={tout}")
    for r in runs:
        print(r["scenario"], r["trial"], r["outcome"], r["tool_calls"], r["llm_calls"],
              r["prompt_tokens"] + r["completion_tokens"], r["detected_at"], r["guard_tripped_at"])


if __name__ == "__main__":
    asyncio.run(main())

Eight conditions, each a single complete run of 30 agent runs:

>_Terminal
python run_loops.py --messages vague --guard none
python run_loops.py --messages vague --guard health
python run_loops.py --messages vague --guard budget
python run_loops.py --messages clear --guard none
python run_loops.py --messages vague --guard none --max-iterations 6
python run_loops.py --messages vague --guard none --prompt assistant
python run_loops.py --messages vague --guard none --model openai:gpt-5-nano
python run_loops.py --messages vague --guard none --model openai:gpt-4.1-mini

summarize.py turns the result files into one table:

Pythonsummarize.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
"""Summarise results/*.json: one row per condition and scenario. No API calls.

python summarize.py results/*.json
"""
import json
import statistics
import sys
from pathlib import Path

PRICE = {  # USD per 1M tokens (input, output), OpenAI list prices
    "gpt-5-mini": (0.25, 2.00), "gpt-5-nano": (0.05, 0.40), "gpt-4.1-mini": (0.40, 1.60),
}


def cost(model, runs):
    pin, pout = PRICE[model]
    return sum(r["prompt_tokens"] * pin + r["completion_tokens"] * pout for r in runs) / 1e6


print(f"{'condition':<34} {'scenario':<13} {'answered':>8} {'tool calls':>14} "
      f"{'tokens/run':>10} {'flagged':>8} {'stopped':>8}")
total = 0.0
for path in sys.argv[1:]:
    runs = json.loads(Path(path).read_text())
    name = Path(path).stem
    model = next(m for m in PRICE if name.startswith(m + "-"))
    total += cost(model, runs)
    for scenario in ("polling", "ping_pong", "empty_search"):
        rs = [r for r in runs if r["scenario"] == scenario]
        if not rs:
            continue
        calls = [r["tool_calls"] for r in rs]
        tokens = [r["prompt_tokens"] + r["completion_tokens"] for r in rs]
        answered = sum(r["outcome"] == "answered" for r in rs)
        flagged = [r["detected_at"] for r in rs if r["detected_at"]]
        stopped = sum(bool(r.get("guard_tripped_at") or r.get("budget_violations")) for r in rs)
        print(f"{name:<34} {scenario:<13} {answered:>5}/{len(rs):<2} "
              f"{statistics.median(calls):>5g} ({min(calls)}-{max(calls)}) "
              f"{round(statistics.mean(tokens)):>10,} {len(flagged):>5}/{len(rs):<2} {stopped:>5}/{len(rs):<2}")
print(f"total cost at list prices: ${total:.2f}")
Output
$ python summarize.py results/*.json
condition                          scenario      answered     tool calls tokens/run  flagged  stopped
gpt-4.1-mini-autonomous-vague-none polling           1/10    15 (7-15)      6,656    10/10     0/10
gpt-4.1-mini-autonomous-vague-none ping_pong        10/10     2 (2-2)      1,129     0/10     0/10
gpt-4.1-mini-autonomous-vague-none empty_search     10/10     2 (2-3)      1,071     0/10     0/10
gpt-5-mini-assistant-vague-none    polling          10/10     2 (2-4)      2,019     1/10     0/10
gpt-5-mini-assistant-vague-none    ping_pong        10/10     2 (1-2)      1,812     0/10     0/10
gpt-5-mini-assistant-vague-none    empty_search     10/10     7 (4-10)      2,711     0/10     0/10
gpt-5-mini-autonomous-clear-none   polling          10/10     2 (2-3)      2,298     0/10     0/10
gpt-5-mini-autonomous-clear-none   ping_pong        10/10     4 (3-5)      4,219     0/10     0/10
gpt-5-mini-autonomous-clear-none   empty_search     10/10     1 (1-8)      3,239     0/10     0/10
gpt-5-mini-autonomous-vague-budget polling          10/10     8 (8-8)      6,521    10/10    10/10
gpt-5-mini-autonomous-vague-budget ping_pong        10/10     8 (5-8)      7,266     0/10     6/10
gpt-5-mini-autonomous-vague-budget empty_search     10/10     8 (8-8)      4,817     0/10    10/10
gpt-5-mini-autonomous-vague-health polling          10/10     3 (3-3)      3,673     0/10    10/10
gpt-5-mini-autonomous-vague-health ping_pong        10/10     8 (4-18)      7,309     0/10     0/10
gpt-5-mini-autonomous-vague-health empty_search     10/10    27 (5-55)     12,814     0/10     2/10
gpt-5-mini-autonomous-vague-none-cap6 polling           0/10     7 (7-7)      3,970    10/10     0/10
gpt-5-mini-autonomous-vague-none-cap6 ping_pong         7/10     9 (2-16)      5,831     0/10     0/10
gpt-5-mini-autonomous-vague-none-cap6 empty_search      5/10    28 (14-50)      6,673     0/10     0/10
gpt-5-mini-autonomous-vague-none   polling           0/10    15 (15-15)      9,148    10/10     0/10
gpt-5-mini-autonomous-vague-none   ping_pong        10/10   6.5 (2-14)      5,507     0/10     0/10
gpt-5-mini-autonomous-vague-none   empty_search      8/10    35 (10-59)     14,114     2/10     0/10
gpt-5-nano-autonomous-vague-none   polling           0/10    15 (15-15)     11,454    10/10     0/10
gpt-5-nano-autonomous-vague-none   ping_pong        10/10     5 (2-9)      6,312     1/10     0/10
gpt-5-nano-autonomous-vague-none   empty_search      0/10    31 (24-41)     19,303    10/10     0/10
total cost at list prices: $0.97

How to read it: answered counts runs that ended with an answer instead of an error. tool calls is the median number of tool calls that ran, with the range. flagged counts runs where the replayed HealthMonitor found an anomaly in the calls that ran. stopped counts runs where the guard or the budget refused at least one call. In the guard rows flagged is 0 because the guard refused the call that would have completed the pattern, so it never ran.


[09]

What the experiment shows

The baseline is gpt-5-mini with the autonomous prompt, vague tool messages and no guard.

gpt-5-mini, 10 runs per cell

Polling

Ping-pong

Empty search

No guard: answered

0/10

10/10

8/10

No guard: tool calls, median

15

6.5

35

No guard: tokens per run

9,148

5,507

14,114

HealthMonitor flagged (replay)

10/10, at call 4

0/10

2/10

Loop guard: answered, tokens

10/10, 3,673

10/10, 7,309

10/10, 12,814

Budget of 8 calls: answered, tokens

10/10, 6,521

10/10, 7,266

10/10, 4,817

Clear tool messages: answered, tokens

10/10, 2,298

10/10, 4,219

10/10, 3,239

Identical calls: easy to detect, easy to stop

Every polling run looped until the iteration limit, and HealthMonitor flagged every one at the fourth tool call: start_export, then three identical status checks. With the guard on, all 10 runs stopped there and answered, using 60% fewer tokens. gpt-5-nano and gpt-4.1-mini looped the same way: 0 of 10 and 1 of 10 answered.

The guarded answers have a flaw that no guard fixes: 8 of 10 promised to keep monitoring the export or to send the link when it was ready, and the other 2 offered to keep polling. An agent whose run has ended can do neither. The guard stops the waste. It doesn't make the agent understand the situation.

Ping-pong: the monitor never saw it

We expected the A-B-A-B detector to catch the ping-pong task. It flagged 0 of 10 gpt-5-mini runs, with or without the guard. The agent never repeated itself exactly. One run made 13 calls: get_account with jo.smith@acme.io, then .com, .co, j.smith@, jo@ and joseph.smith@ variants, and find_account with "Jo Smith", "Jo", "Smith", "Acme" and "acme.io". That's a loop to a human and 13 different calls to a detector that compares argument hashes. Guessing email addresses is also worse than it looks: with a real customer database, one guess could have returned someone else's account.

The ping-pong runs did all end with an answer, after a median of 6.5 calls. The budget of 8 refused calls in 6 of 10 runs, and the agent then answered. Tokens per run went *up* slightly with the budget (7,266 against 5,507), because a refusal adds a model turn and the runs that hit the cap were the long ones. A cap bounds the worst case. It doesn't make the average run cheaper.

Empty results: the expensive loop nobody detects

The empty search was the costliest pattern: a median of 35 searches and 14,114 tokens per run, up to 59 calls, for a question whose answer is "there is no such policy". The monitor flagged 2 of 10 runs, at calls 36 and 41, and only because the agent had wandered off: in 6 of 10 runs it called start_export, a tool unrelated to the question, and the flagged runs were polling that export. The guard tripped in 2 of 10 runs the same way, and saved little. The budget stopped all 10 runs at 8 searches and cut tokens by 66%.

gpt-5-nano was worse: all 10 empty-search runs hit the iteration limit, with 31 calls and 19,303 tokens per run, and all 10 called start_export. gpt-4.1-mini searched twice and answered every time.

Clear tool messages beat every guard

With TOOL_MESSAGES=clear, the same model, prompt and tasks needed a median of 2 polling calls, 4 ping-pong calls and 1 search. All 30 runs answered, and every scenario used fewer tokens than with any guard: 75% fewer than the baseline for polling and 77% fewer for the empty search. The answers were also better: the agent told the user the link would arrive by email, and on the ping-pong task it looked up only the two addresses find_account returned, then asked the user to confirm, in 10 of 10 runs. It still retried the original address once or twice in most runs, although the message said retrying wouldn't help.

A clear message isn't a guarantee. In 4 of 10 clear-message search runs the agent still called start_export, after it had learned the policy didn't exist. That's a different failure, scope creep rather than looping, and a reason to keep a cap even with good tools.

An iteration cap is a blunt fix

Lowering max_agent_iterations to 6 stopped runs sooner but ended many good ones with an error instead of an answer. Polling still answered 0 of 10. Ping-pong answered 7 of 10 instead of 10, and the empty search 5 of 10 instead of 8, because the cap ends the run instead of asking for an answer. Each capped run also made 7 model calls and 7 tool calls, not 6, the off-by-one we report in our bug log for 1.3.3. Prefer the tool-call budget: it refuses the call and lets the agent answer.


[10]

How to stop an AI agent from looping

In order of value, from these runs:

1. Make tool results say what to do next. "Pending. Checking again won't change it; the link is emailed" ended the polling loop in 2 calls. Write errors for a model that will retry, not for a human reading logs. 2. Set a hard tool-call budget on every autonomous run. max_tool_calls_per_run caught all three loop types in our runs, refuses before the tool runs, and lets the agent answer. 3. Add a loop guard for identical calls. It stops polling loops at the third repeat and saves more tokens than the budget there. Don't expect it to catch loops whose arguments change. 4. Watch the prompt. "Don't ask questions, keep working" turned two-call tasks into 15-call loops in our smoke tests. If the agent can't ask a human, give it an explicit way to finish without one. 5. Record tool calls with real arguments when you debug loops. Redacted traces make different calls look identical.


[11]

Honest limits

  • One task per loop type, 10 runs per cell. These are small samples of three toy tools. 0 of 10 against 10 of 10 is a strong signal; 7 of 10 against 10 of 10 is not.

  • The guard drives an async API by hand. _now() depends on HealthMonitor.record_tool_call never awaiting anything, which is true of 1.3.3 and not a documented guarantee. It raises if that changes.

  • The monitor is exact-match. Stuck and loop detection compare argument hashes, so rephrased searches and guessed email variants look like progress. Neither Promptise detector measures whether results are changing, and we didn't build one.

  • `on_anomaly` acts after the run. In the autonomous runtime, HealthConfig(on_anomaly=...) pauses or escalates when the invocation ends. Stopping mid-run, as here, takes the callback guard or the budget.

  • Default limits are not what they seem. The default ReAct agent stops at 15 reason iterations, not at max_agent_iterations=25, and in 1.3.3 max_agent_iterations=N allows N+1 model calls.

  • Smoke tests aren't in the table. The observation that the polite prompt rarely loops comes from about 24 unrecorded smoke runs; the assistant row in the table is the measured version.

  • Costs are list-price estimates. The 240 runs used 999,600 input and 499,065 output tokens, $0.97 at OpenAI's list prices (gpt-5-mini $0.25 and $2.00 per million input and output tokens, gpt-5-nano $0.05 and $0.40, gpt-4.1-mini $0.40 and $1.60). Prompt caching makes the real bill lower.


[12]

Frequently asked questions

Why does my AI agent keep calling the same tool?

Usually because the tool result gives it no reason to stop: a "pending" status, an empty list, or an error that suggests trying again. Our polling task looped in 10 of 10 runs with a bare "pending" status and stopped after 2 calls when the result said that checking again wouldn't change it.

How do I detect an AI agent loop?

Record every tool call with its arguments and look for repeats. Promptise's HealthMonitor flags the same call three times in a row and repeating call sequences, without any LLM calls. It caught every identical-call loop at the fourth call, but none of the loops where the agent changed its arguments each time, so pair it with a tool-call budget.

How do I stop an agent loop without losing the answer?

Refuse the next tool call instead of ending the run. A refused call returns an error to the model as the tool result, and the model then answers with what it has. In our runs that turned 0 of 10 answered polling runs into 10 of 10. Lowering the iteration limit ends the run with an exception instead.

What is a good max_tool_calls_per_run value?

Measure your normal runs first and set the cap above them. With clear tool messages our tasks needed a median of 1 to 4 calls and at most 8, so a cap of 8 left those runs alone and cut the worst loops from up to 59 calls to 8. Use the observability collector to get the distribution from real traffic.

Does a better model stop agent loops?

Not reliably. gpt-4.1-mini answered the ping-pong and empty-search tasks in 2 calls but looped on polling in 9 of 10 runs. gpt-5-nano looped on polling and on the empty search in every run. Test the model you deploy with an eval suite that includes loop-prone tasks.


[13]

Go deeper

  • AI Agent Cost Control: Budgets That Stop Runaway Loops: per-run and per-day budgets, cost-weighted tools and escalation.

  • How to Evaluate AI Agents in Python: add loop-prone tasks to an eval suite and measure pass^k.

  • AI Agent Observability: Trace Every Tool Call: the collector that recorded every call in this guide.

  • Self-Improving AI Agents in Python: agents that learn from failed tool calls.

  • In the Promptise docs: Behavioral health, Autonomy budget and Engine limits.

Learning paths

Want more structure? Paths put guides in order, like a short course.

See the paths →

Keep going.

Browse every guide by topic and level, or follow a learning path that puts them in order.

All guidesLearning paths