Building Reliable AI Agents: Retries, Idempotency, Tool Design

AI AgentsReliabilityDistributed SystemsLLM

Most "unreliable AI agent" problems have nothing to do with the model. They're the same problems distributed systems engineers have fought for decades — retries, timeouts, partial failures, duplicate side effects — wearing a new hat. The model just makes them worse, because now the thing deciding when to call a tool is non-deterministic and occasionally makes things up.

I build agents that touch real systems: charging sessions, payments, work orders. When an agent's tool call has consequences in the physical world, "usually works" isn't good enough. Reliable AI agents come from applying boring, proven engineering discipline to the tool layer, not from a cleverer prompt.

Design tools like a public API, not a function call

The agent is an untrusted, non-deterministic client of your tools. Treat every tool the way you'd treat an endpoint exposed to the internet: validate inputs, return structured errors, and never assume the caller will behave.

That means tools should have narrow, well-typed inputs and outputs. A tool called updateOrder(anything) invites the model to send garbage; a tool called setOrderStatus(orderId: string, status: "shipped" | "cancelled") constrains it. Use structured outputs and function calling so the model's tool arguments are schema-validated before your code ever runs. Reject invalid arguments with a clear message the model can act on, rather than half-executing.

Idempotency is the load-bearing wall

Here's the failure that will bite you: the agent calls chargeCard, the call succeeds on the server, but the response times out before reaching the agent. The agent, seeing no response, retries. Now you've charged twice.

The fix is the same one payment systems use — idempotency keys. The agent (or the orchestration layer) generates a key per logical operation; the tool deduplicates on it.

def charge_card(amount, customer_id, idempotency_key):
    existing = ledger.get(idempotency_key)
    if existing:
        return existing            # already done — return prior result
    result = payment_gateway.charge(amount, customer_id)
    ledger.put(idempotency_key, result)
    return result

With this, a retry is safe: the same key returns the same result instead of acting again. Any tool with side effects — sending a message, creating a record, moving money — needs this. Read-only tools (search, fetch) are naturally idempotent and don't. This is exactly the discipline from idempotency in distributed systems, applied to the agent's hands.

Retries: bounded, backed off, and only when they can help

Naive agents retry everything, which burns tokens and time on errors that will never succeed. The tool's error contract has to tell the agent whether a retry is worth attempting. I split errors into three kinds:

Error class Example Agent response
Transient timeout, 503, rate limit Retry with backoff, bounded attempts
Permanent validation failure, 404 Don't retry; change approach or stop
Business rule "insufficient funds" Don't retry; report to user/escalate

Encode this in the returned error so the model isn't guessing:

{
  "error": {
    "type": "rate_limit",
    "retryable": true,
    "retry_after_ms": 2000,
    "message": "Provider rate limit hit."
  }
}

Cap total attempts (I usually allow 2-3 retries per tool call) and put backoff between them. And crucially, cap the whole agent loop — a max step count and a wall-clock deadline — so a confused agent can't spin forever racking up cost. An agent stuck retrying a permanent error is a runaway bill.

Timeouts and the "did it happen?" problem

Every tool call needs a timeout, because a hung call blocks the whole agent. But a timeout creates ambiguity: you don't know whether the operation completed. This is why idempotency and timeouts are a package deal — the timeout lets you give up waiting, and idempotency lets you safely retry to find out. Where you can, make tools return the current state ("order is now shipped") rather than an event ("shipped it"), so a retry that reads state is always safe and informative.

Make the agent's plan inspectable

Reliability isn't only about not breaking things; it's about knowing what happened when something does. Every tool call, its arguments, its result, and every model decision should be traced. When an agent does something wrong at 3am, you need the full trajectory to debug it, and aggregate traces tell you which tools fail most and where loops stall.

This is where LLM observability with OpenTelemetry pays for itself. Emit a span per step with the model's reasoning, the tool called, and the outcome. Without this, "the agent sometimes does the wrong thing" is unfixable; with it, you can see the exact fork where it went sideways.

Guard the boundary

A reliable agent also refuses to do the wrong thing on purpose. Two layers matter:

The loop, assembled

Putting it together, a reliable agent loop looks like this:

while step < MAX_STEPS and now < DEADLINE:
    action = model.decide(state)         # non-deterministic, validate it
    if action is final_answer: return it
    validate(action.tool, action.args)   # schema check before executing
    result = call_tool_with(
        timeout, idempotency_key, bounded_retries_on_transient_errors
    )
    trace(step, action, result)          # observability
    state = update(state, result)

Nothing here is exotic. It's timeouts, idempotency keys, typed errors, bounded loops, and tracing — the toolkit for reliable systems generally. The shift in mindset is accepting that the agent is a flaky, creative client you can't fully control, so you make the tools and the loop robust enough that its mistakes are bounded and recoverable.

The short version

Do these five things and your agent goes from "impressive demo" to "thing I trust with a production credit card." The model gets the headlines; the reliability comes from the plumbing.

Resources

Frequently asked questions

What makes an AI agent unreliable?

Non-deterministic model output combined with side-effecting tools and naive retries. If a tool call charges a card or sends an email and the agent retries after a timeout, you get duplicate actions. Reliability comes from idempotent tools, bounded retries, and clear error contracts.

How do you make agent tools idempotent?

Give each side-effecting operation a client-supplied idempotency key so repeated calls with the same key produce one result. The tool checks whether that key was already processed and returns the prior result instead of acting twice, which makes retries safe.

Should AI agents retry failed tool calls automatically?

Yes, but only for transient, retryable errors, with bounded attempts and backoff. Retrying validation errors or business-rule rejections just wastes tokens and time. The error type must tell the agent whether a retry can possibly succeed.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →