Writing a multi-agent system in Antigravity works beautifully in the demo, and then you push it to production and watch the same tool fire ten, twenty, thirty times in a row. I've been there more than once. The first instinct is to blame the model, but nearly every time the root cause has been in the design or the prompt.
This article classifies the four kinds of agent loops I keep seeing, and shares the guardrails I now put in from the start. Once you know which variety you're dealing with, the fix is often a prompt tweak and a few dozen lines of code.
Four flavors of agent loop
From my experience, runaway agent behavior falls into one of these four buckets. Different bucket, different fix — so diagnosis starts by identifying which one you have.
- A. Tool-call loop — the agent calls the same tool with the same args, repeatedly
- B. Subagent recursion loop — an agent spawns another of the same type, indefinitely
- C. Replan loop — Plan → try → fail → replan, forever
- D. Completion-check loop — the acceptance criteria are vague, so the agent "feels" like there's still work to do
A and D are the most common, B is a design bug, C is usually a prompt bug.
A. Tool-call loops — the most common type
The classic pattern:
agent: search_docs(query="auth flow")
-> []
agent: search_docs(query="auth flow") # retries with identical args
-> []
agent: search_docs(query="auth flow") # foreverTwo causes. First, the tool's return value is ambiguous — the model can't tell "nothing found" from "temporary failure." Second, there's no guardrail that says "don't call the same tool with the same args."
Fix 1: make the tool return value explicit
def search_docs(query: str) -> dict:
"""Return an explicit status so the model can reason about empties."""
results = vector_search(query)
if not results:
return {
"status": "empty",
"query": query,
"suggestion": "Try a more specific or different keyword.",
"results": [],
}
return {
"status": "ok",
"results": [{"title": r.title, "excerpt": r.excerpt[:500]} for r in results],
}Returning status: "empty" with a suggestion helps the model pivot. Returning [] usually gets interpreted as "maybe I was unlucky, try again."
Fix 2: a repeat-call guardrail
from collections import deque
import json
class ToolCallGuard:
"""Detect identical tool calls within a rolling window."""
def __init__(self, window: int = 5, max_repeats: int = 2):
self.window = window
self.max_repeats = max_repeats
self.recent = deque(maxlen=window)
def check(self, tool_name: str, args: dict) -> bool:
"""Return False if this exact call has already happened too many times."""
key = (tool_name, json.dumps(args, sort_keys=True))
count = sum(1 for k in self.recent if k == key)
if count >= self.max_repeats:
return False
self.recent.append(key)
return True
guard = ToolCallGuard()
def safe_call(tool_name, args, tools):
if not guard.check(tool_name, args):
return {
"status": "blocked",
"reason": f"Tool {tool_name} was called with identical args {args} too many times. Try a different approach.",
}
return tools[tool_name](**args)When the guard blocks, the message to the model should read "you're repeating yourself, pivot." That single feedback line stops most of the loops I've seen.
B. Subagent recursion loops
Antigravity's power partly comes from agents spawning subagents. The flip side: an agent invoking another of its own type recursively gives you an unbounded call chain.
Two defenses. Put a depth cap on agent invocations, and declare explicitly which subagent types may not be invoked from within a given agent.
class AgentContext:
"""Track execution depth and the chain of agent types invoked so far."""
def __init__(self, max_depth: int = 3, call_chain: tuple = ()):
self.max_depth = max_depth
self.call_chain = call_chain
def can_invoke(self, agent_type: str) -> tuple[bool, str]:
if len(self.call_chain) >= self.max_depth:
return False, f"max agent depth {self.max_depth} reached"
if agent_type in self.call_chain:
return False, f"recursion detected ({agent_type} already in chain)"
return True, ""
def child(self, agent_type: str) -> "AgentContext":
return AgentContext(self.max_depth, self.call_chain + (agent_type,))The design goal: "no agent runs at greater depth than the designer intended." Three levels is plenty for a useful system. If you feel the urge to go deeper, the real answer is probably collaboration between peers, not deeper hierarchy.
C. Replan loops
"Decompose, execute, fail, re-decompose, execute, fail…" This is usually a prompt problem. It shows up when:
- Step-level success criteria are vague, so pass/fail judgment is left to the model
- Error information from a failed subtask isn't carried into the next plan
- "Replan" is always available, with no explicit retreat condition
The fix is to count replans and put a hard cap on them.
class PlanController:
"""Manage plan/execute cycles with a replan cap."""
def __init__(self, max_replans: int = 2):
self.max_replans = max_replans
self.replan_count = 0
self.failure_history: list[dict] = []
def can_replan(self) -> bool:
return self.replan_count < self.max_replans
def record_failure(self, step: str, error: str):
self.failure_history.append({"step": step, "error": error})
def replan_prompt(self) -> str:
if not self.can_replan():
return ("Replan limit reached. Write a final report based on what is complete, "
"explicitly listing what remains unfinished.")
self.replan_count += 1
past = "\n".join(f"- {h['step']}: {h['error']}" for h in self.failure_history)
return f"Revise the plan considering these failures:\n{past}"The point is to provide a graceful retreat: "if you fail twice, wrap up with what's done." Without that, you're asking the model to chase perfection forever.
D. Completion-check loops
When the finish line is fuzzy, the agent will keep feeling there's more to do. The fix is direct — define acceptance criteria up front and make the prompt converge on them.
def build_agent_prompt(task: str, acceptance_criteria: list[str]) -> str:
criteria_str = "\n".join(f"- {c}" for c in acceptance_criteria)
return f"""Task: {task}
This task is complete when ALL of the following hold:
{criteria_str}
Important:
- Call report_complete the moment the criteria are satisfied.
- You do not need to be perfect beyond the criteria.
- If you reach 15 steps without completion, call report_partial with the current state.
"""Explicit acceptance_criteria remove the "not sure what else to do" limbo. Pair it with a step cap as a safety net.
The three guardrails I always deploy
Beyond design and prompt changes, I always ship these runtime guards:
- Total step cap — 25 steps max per task. Over that, force-terminate with an error report
- Wall-clock cap — tasks over 5 minutes get cancelled
- Per-tool call cap — warn at 8 identical tool uses, block at 10
Log which limit triggered. When loops happen again (and they will), the logs tell you immediately which class you're back in.
Anti-patterns I keep seeing
- Infinite automatic retries — network-error retries without exponential backoff and a ceiling are a loop in disguise
- Letting the same model judge its own completion — the executor and the judge should ideally be different models, or the completion check should be a structured rule rather than an LLM call
- Swallowing error info — if a tool failure surfaces as an empty string, the model has no idea what happened and will try again. Return structured error objects
The first step to take
Agent runaways almost always point to missing constraints, not missing model capability. Start by adding ToolCallGuard and logging whenever it fires. Just that visibility — "my agent called search_docs 11 times in 30 seconds" — will tell you exactly where to intervene. The aim isn't a perfect agent; it's an agent you can confidently stop.