DevgainsDevgainsDevgains
All articles

Agentic Tool-Calling Loops That Don't Spiral Out of Control

·5 min read

An agent is, stripped of mystique, a while loop: the model picks a tool, you run it, you feed the result back, and you repeat until the model decides it's done. That loop is also where every production agent disaster lives. Left unbounded, it will call the same search three times in a row, ping-pong between two tools forever, exhaust your token budget on a task it can't actually complete, or hang waiting on a tool that never returns.

The good news is that taming the loop is an engineering problem, not a prompting one. You don't fix a runaway agent by adding "please be efficient" to the system prompt. You fix it with budgets, idempotency, termination conditions, and observability — the same disciplines you'd apply to any retrying distributed system. Let's build the loop properly.

The core loop and where it breaks

Here is the anatomy every agent shares, regardless of framework:

def run_agent(client, tools, user_msg, max_steps=10):
    messages = [{"role": "user", "content": user_msg}]
    for step in range(max_steps):
        resp = client.messages.create(
            model="claude-sonnet-4-5",
            max_tokens=2048,
            tools=tools,
            messages=messages,
        )
        messages.append({"role": "assistant", "content": resp.content})
 
        if resp.stop_reason != "tool_use":
            return resp  # model is done — natural termination
 
        tool_results = []
        for block in resp.content:
            if block.type == "tool_use":
                result = dispatch(block.name, block.input)  # YOUR code runs a tool
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": result,
                })
        messages.append({"role": "user", "content": tool_results})
 
    raise RuntimeError("Agent hit max_steps without finishing")

This works on a good day. The Anthropic tool use docs and OpenAI function calling guide describe this same request/respond cycle. The failures all happen in the gaps: what if the model never emits a non-tool_use stop? What if dispatch throws? What if the model loops calling search with the same query? Each gap needs an explicit control.

Bound everything: steps, time, and tokens

max_steps is your first and most important guardrail — but it's a blunt one. A single step can be cheap or it can read a 50k-token document. Bound the loop on three independent axes:

  • Step count — a hard ceiling on tool-calling iterations.
  • Wall-clock time — a deadline, so a slow tool can't hang the whole task.
  • Token budget — track cumulative input+output tokens and stop when you cross a cost ceiling.
import time
 
def run_bounded(client, tools, user_msg, max_steps=10,
                deadline_s=60, token_budget=100_000):
    messages = [{"role": "user", "content": user_msg}]
    start, tokens_used = time.monotonic(), 0
 
    for step in range(max_steps):
        if time.monotonic() - start > deadline_s:
            return finalize(messages, reason="deadline")
        if tokens_used > token_budget:
            return finalize(messages, reason="token_budget")
 
        resp = client.messages.create(model="claude-sonnet-4-5",
                                      max_tokens=2048, tools=tools, messages=messages)
        tokens_used += resp.usage.input_tokens + resp.usage.output_tokens
        # ... handle tool calls as before ...
    return finalize(messages, reason="max_steps")

Never run an agent loop without a hard step ceiling, even behind a deadline. A model stuck calling a fast tool can burn hundreds of iterations inside your 60-second window. The two limits catch different failure modes — keep both.

Make tools idempotent and handle their errors

The single most destabilizing thing you can do is let a tool throw an unhandled exception or return a raw stack trace into the conversation. The model sees garbage, panics, and retries randomly. Instead, catch every tool error and return a structured, model-readable result describing what went wrong and what to do next.

def dispatch(name, args):
    try:
        return TOOLS[name](**args)
    except KeyError:
        return {"error": f"Unknown tool '{name}'. Available: {list(TOOLS)}"}
    except ValidationError as e:
        return {"error": "invalid_arguments", "detail": str(e),
                "hint": "Fix the arguments and call again."}
    except Exception as e:
        return {"error": "tool_failed", "detail": str(e)[:200]}

A clean error message is information the model can act on; a thrown exception is a dead end. Also make tools idempotent where you can — if the model calls create_ticket twice with the same payload, the second call should return the existing ticket, not open a duplicate. Agents will retry; design tools so retries are safe.

Detect and break loops

Bounded steps stop infinite loops eventually, but you want to catch degenerate loops sooner — the model calling the identical tool with identical arguments repeatedly, making no progress. Hash each (tool_name, args) call and track repetition:

from collections import Counter
import hashlib, json
 
def call_signature(name, args):
    blob = name + json.dumps(args, sort_keys=True)
    return hashlib.sha256(blob.encode()).hexdigest()[:16]
 
seen = Counter()
# inside the loop, before dispatching:
sig = call_signature(block.name, block.input)
seen[sig] += 1
if seen[sig] >= 3:
    result = {"error": "repeated_call",
              "hint": "You've made this exact call 3 times. Try a different "
                      "approach or conclude with what you know."}

Feeding that nudge back into the conversation usually breaks the model out of the rut without killing the task outright. It's a far better outcome than silently burning your step budget.

Observe the loop or you're flying blind

Every step should emit a structured trace: step index, which tool, arguments, latency, result size, tokens consumed, and the cumulative running totals. When an agent misbehaves in production, this trace is the difference between a five-minute diagnosis and an afternoon of guessing.

Log one structured event per tool call with a shared trace ID. The patterns you're hunting — loops, slow tools, runaway token growth — are obvious in aggregated traces and nearly invisible from logs of individual requests.

Frameworks like LangGraph formalize the loop as a state machine with explicit edges and built-in checkpointing, which gives you these controls — recursion limits, interrupts, persistence — without hand-rolling them. Whether you adopt one or write your own, the controls are the same. The framework is optional; the discipline is not.

Takeaways

  • An agent is a tool-calling loop; treat it like any retrying system, not a magic black box.
  • Bound it on three axes — step count, wall-clock deadline, and token budget — and keep all three.
  • Catch every tool error and return structured, actionable results instead of raw exceptions.
  • Make tools idempotent so the model's inevitable retries don't cause duplicate side effects.
  • Detect degenerate repeated calls and nudge the model out before exhausting your budget.
  • Emit a structured trace per step — observability is what makes production agents debuggable.
5 min read

Read next