Building a Reliable Agentic Loop: Retries, Tool Errors, and Knowing When to Stop

Building a Reliable Agentic Loop: Retries, Tool Errors, and Knowing When to Stop

6 37
calendar_today agoschedule9 min read

Every "build an AI agent" tutorial shows you the same twelve lines: a while loop, a call to the model, a check for tool calls, execute them, feed the results back, repeat. It runs, the agent uses a tool, you feel like a wizard. Then you put it in front of real inputs and it does one of three things: loops forever, retries a tool that returned a permanent 404 ten times in a row, or confidently finishes a task it never actually completed.

Here's the thing the tutorials leave out: the loop is the easy part. The reliability around the loop is the actual engineering. The happy path is a demo. What separates an agent you can deploy from an agent you have to babysit is entirely in the stuff that isn't the happy path — how it handles tool failures, how it decides it's done, and how it refuses to run forever.

This is a field guide to that reliability layer, in real Python. We'll start from the naive loop, then add the four things production agents need and demos never have: a stop condition that isn't just "no more tool calls," error handling that tells the difference between retry this and give up, a hard budget so the loop can't run away, and structured logging so you can see why it did what it did.

The naive loop — and exactly where it breaks

Here's the canonical agentic loop, the one everyone starts with:

def run_agent(client, messages, tools):
    while True:
        response = client.chat(messages=messages, tools=tools)
        messages.append(response.message)

        if not response.tool_calls:
            return response.message.content  # model answered — done

        for call in response.tool_calls:
            result = TOOLS[call.name](**call.arguments)   # <-- everything wrong lives here
            messages.append(tool_result(call.id, result))

The structure is correct. It's the unstated assumptions that kill it in production:

  • It trusts the tool to succeed. TOOLS[call.name](**call.arguments) has no error handling. One exception and the whole agent crashes mid-task.
  • It trusts the model to stop. The only exit is "the model produced no tool calls." A model stuck in a plan-execute-replan spiral never hits that, and while True means forever.
  • It trusts the model to call real tools with valid arguments. TOOLS[call.name] KeyErrors on a hallucinated tool name; **call.arguments TypeErrors on a malformed argument object.
  • It has no memory of cost. No token budget, no step cap. A runaway loop is a runaway bill, discovered on the invoice.

Every one of these is a real incident waiting to happen. Let's close them one at a time.

Fix 1: Tool errors are messages, not crashes

The single most important shift: a tool failure is not an exception that should propagate — it's information the agent needs. When a tool fails, the model should see the failure, reason about it, and either retry differently or route around it. That only works if you catch the error and feed it back as a tool result the model can read.

def execute_tool(call):
    fn = TOOLS.get(call.name)
    if fn is None:
        # Hallucinated tool — tell the model, don't crash.
        return f"Error: tool '{call.name}' does not exist. Available: {list(TOOLS)}"
    try:
        return str(fn(**call.arguments))
    except TypeError as e:
        # Bad arguments — the model can correct these if you let it see them.
        return f"Error: invalid arguments for '{call.name}': {e}"
    except Exception as e:
        # Real failure — return a legible, model-readable error.
        return f"Error calling '{call.name}': {e}"

Notice the errors are written for a model to read, not for a log file. "Error: tool 'serch_web' does not exist. Available: ['search_web', ...]" lets the model notice its typo and self-correct on the next turn. A raw KeyError traceback teaches it nothing. This is the difference between an agent that recovers and one that dies: your error strings are part of the agent's reasoning surface, so write them like instructions, not like stack traces.

But not every error should be retried. This is where naive agents burn budget: a 404 Not Found or 403 Forbidden is permanent — retrying it is pure waste. A 429 Rate Limited or 503 is transient — retrying (with backoff) is exactly right. Teach the distinction:

PERMANENT = {400, 401, 403, 404, 422}   # don't retry — the request itself is wrong
TRANSIENT = {429, 500, 502, 503, 504}   # retry with backoff — the world is temporarily busy

def classify_error(status):
    if status in PERMANENT:
        return "permanent"   # tell the model to try a different approach
    if status in TRANSIENT:
        return "transient"   # retry the same call after a wait
    return "unknown"

For transient failures, wrap the call itself in bounded retries with exponential backoff — but cap the attempts. "Retry until it works" is how you turn a brief outage into an infinite loop. Knowing which failures the model should reason about versus which the infrastructure should silently retry is a core design decision in building autonomous AI agents, and getting it wrong is the most common reason agents feel flaky.

Fix 2: Stop conditions — "no tool calls" is not enough

The naive loop has exactly one exit. Real agents need several, because there are several distinct ways an agent's run should end — and only one of them is "success."

def run_agent(client, messages, tools, max_steps=15, token_budget=100_000):
    steps = 0
    tokens_used = 0

    while True:
        # --- Stop condition A: step cap (guards against loops) ---
        if steps >= max_steps:
            return finish("max_steps_exceeded", messages)

        # --- Stop condition B: token budget (guards against cost) ---
        if tokens_used >= token_budget:
            return finish("budget_exhausted", messages)

        response = client.chat(messages=messages, tools=tools)
        tokens_used += response.usage.total_tokens
        steps += 1
        messages.append(response.message)

        # --- Stop condition C: the model answered (the good exit) ---
        if not response.tool_calls:
            return finish("completed", messages, answer=response.message.content)

        # --- Otherwise: execute tools and continue ---
        for call in response.tool_calls:
            result = execute_tool(call)
            messages.append(tool_result(call.id, result))

Four ways out, and the difference between them matters to your caller:

  1. Completed — the model produced a final answer. Success.
  2. Max steps exceeded — the loop hit its ceiling. This is a failure signal, not a silent stop: surface it, because it usually means the task was underspecified or a tool is misbehaving.
  3. Budget exhausted — cost cap hit. Same: a signal, not a shrug.
  4. (Add as needed) explicit finish tool — for complex agents, give the model an explicit submit_answer tool so finishing is a deliberate act, not merely the absence of tool calls. This is more robust than inferring completion, because "no tool calls" can also mean the model got confused.

The critical anti-pattern: while True with a single exit is a production incident with a countdown timer. Always bound the loop by steps and by cost. And when you exit on a bound rather than on success, say so — a caller that can't tell "here's your answer" from "I gave up after 15 steps" will happily ship the second as if it were the first.

Fix 3: Detecting the loop-within-the-loop

The subtler failure isn't infinite iteration — your step cap catches that. It's the agent that makes progress-shaped motion without progressing: calling the same tool with the same arguments three times, or ping-ponging between two tools forever. The step cap eventually stops it, but you've burned 15 steps to accomplish nothing.

Detect it by tracking recent actions and noticing repetition:

from collections import deque

recent = deque(maxlen=4)

def is_stuck(call):
    signature = (call.name, str(sorted(call.arguments.items())))
    if recent.count(signature) >= 2:      # same exact call, 3rd time
        return True
    recent.append(signature)
    return False

When you detect a stuck agent, the fix isn't to kill it — it's to interrupt with a nudge: inject a message like "You've called search_web('AI agents') three times with identical arguments and gotten the same result. Try a different approach or provide your best answer with what you have." Often the model just needed to be told it was in a rut. Deciding what the agent remembers across steps — the running transcript, prior tool results, what to summarize versus keep verbatim — is its own discipline. Let the message history grow unbounded and you'll blow the context window mid-task; that trade-off between memory and budget is exactly what context engineering for AI agents is about, and it's where loop reliability and cost control meet.

Fix 4: You can't fix what you can't see

An agent is a non-deterministic system making autonomous decisions. When it does something wrong — and it will — "it just didn't work" is useless. You need to reconstruct what it did and why. That means logging every step as a structured record, not a print statement:

def log_step(step, response, tokens_used):
    log.info("agent_step", extra={
        "step": step,
        "tool_calls": [c.name for c in response.tool_calls],
        "finish_reason": response.finish_reason,
        "tokens_this_step": response.usage.total_tokens,
        "tokens_cumulative": tokens_used,
    })

With this, a failed run becomes a readable trace: step 1 called search_web, step 2 called a tool that doesn't exist (typo — good, now you know), step 3 retried, step 4 hit the step cap. Without it, you're re-running a non-deterministic system and praying it misbehaves the same way twice. Structured, step-level observability is what makes agents debuggable instead of mysterious — and it's the same instinct behind treating agent behavior as something you evaluate systematically in production rather than eyeball. If you can't answer "why did the agent do that?" from your logs alone, you don't have observability — you have hope.

Putting it together

The reliable loop isn't more clever than the naive one — it's more defensive. Same twelve-line skeleton, wrapped in four guarantees:

  1. Tool errors become messages the model can reason about — with permanent-vs-transient classification so it retries the right things and gives up on the rest.
  2. Multiple stop conditions — success, step cap, budget cap — and it tells the caller which one fired.
  3. Loop-within-loop detection that nudges a stuck agent instead of letting it spin to the step limit.
  4. Structured per-step logging so every run is reconstructable after the fact.

None of this is exotic. It's the ordinary engineering of turning a non-deterministic component into a dependable system — the same discipline you'd apply to any service that calls unreliable dependencies, which is exactly what a tool-using agent is. Building agents that survive contact with production, wiring them to real tools and real budgets, is the whole subject of a hands-on course on building AI applications in Python, and the reliability layer above is the part that separates a weekend demo from something you'd put a budget behind. If your day-to-day is coding agents specifically, the patterns for stop conditions, subagents, and error recovery go deeper still in a course on agentic coding with Claude Code.

Conclusion

The agentic loop is genuinely simple — that simplicity is exactly why so many agents ship broken. The while True and the tool dispatch are the parts you can write in your sleep; the parts that decide whether the thing works in front of real users are the ones the tutorials skip. What happens when a tool fails. How the loop knows it's done. What stops it from running forever. Whether you can tell, afterward, why it did what it did.

Start from the naive loop, then earn each guarantee: errors as messages, bounded stops, stuck-detection, structured logs. Add them and your agent stops being a party trick that works on the demo input and becomes something you can point at real work and trust — or at least trust to fail loudly, cheaply, and legibly, which in autonomous systems is the same thing as trustworthy.

Write the loop in five minutes. Spend the rest of the week on everything around it. That's where the reliability was hiding the whole time.


Sources & further reading:

This article is educational content. Agent frameworks and provider SDKs evolve quickly; treat the code above as illustrative patterns and validate against current library documentation before shipping.

1 Comment

0 votes
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Sovereign Intelligence: The Complete 25,000 Word Blueprint (Download)

Pocket Portfolio - Apr 1

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelskiverified - Mar 19

Architecting a Local-First Hybrid RAG for Finance

Pocket Portfolio - Feb 25

The Sovereign Vault — A Comprehensive Guide to Protocol-Driven AI

Ken W. Algerverified - Jun 4

The Privacy Gap: Why sending financial ledgers to OpenAI is broken

Pocket Portfolio - Feb 23
chevron_left
686 Points43 Badges
19Posts
8Comments
13Connections
Founder of Cursuri-AI.ro and Co-Founder of ProtectAds.com. Passionate about scalable architectures, ... Show more

Related Jobs

View all jobs →

Commenters (This Week)

2 comments
2 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!