An Agent That Cannot Fail Will Retry Forever. Budget Tokens and Wall-Clock, Not Retry Counts.
Your max_retries=3 never fires, because every search call succeeds. The agent just keeps finding new ways to look for an answer that doesn't exist. Here is the budget that actually stops it, and the exit that lets it stop politely.
A research agent is asked for the 2019 revenue of a small private company. That number was never published. The agent searches, finds nothing, rewords the query, tries a different site, re-plans, and searches again. Your max_retries=3 setting never fires, because no call ever failed. Every search returned a clean response with zero useful results. The next morning the run is still going.
The problem isn't a bug in the retry logic. The agent has no way to fail, so the only way out of its loop is success, and success isn't available.
🔍 Why retry counts don't catch this
A retry count answers a narrow question: how many times should I repeat this exact call if it errors? That's the right tool for a flaky network request. It's the wrong tool for an agent, for three reasons.
The counter resets on every new attempt. When the agent rewords its query, that's a new tool call with a fresh counter. From the retry layer's point of view, nothing is being retried at all.
Unproductive work isn't an error. A search that returns nothing useful, a page that doesn't contain the answer, a plan that leads nowhere: all of these come back as successful calls. The retry layer only sees failures, and there are none.
The counts multiply. Three retries per call, inside ten steps per plan, inside a planner allowed to re-plan five times, is not "three." It's a ceiling of 150 calls that nobody chose on purpose, and if re-planning has no limit, there's no ceiling at all.
So the agent keeps working, keeps looking busy, and keeps spending.
What to budget instead
Stop counting attempts and start measuring what the run actually consumes. Two measures do most of the work.
Tokens: the cost of thinking
Every loop turn reads the growing conversation and writes a response, so token usage tracks how much work the agent has really done, including all the rewording and re-planning a retry counter can't see. A token budget is set per task, not per call, which is what makes it catch the loop.
Wall-clock: the cost of waiting
Tokens miss one case: the agent that sits waiting on a slow tool, a hung browser page, or a rate limiter. It uses almost no tokens while stuck. A wall-clock deadline for the whole task catches that, and it's also the number your users actually feel.
A step cap as a backstop
Keep a maximum number of loop turns too, but treat it as a safety net for a broken budget check, not the main control. A step is a poor unit of work: one step can be a tiny lookup or a huge document read.
💡 The budget needs an exit the agent can take
Here's the part most setups miss. If you only add a hard cutoff, the agent gets killed mid-thought and the user gets nothing: no answer, no explanation, not even the list of sources it checked. Give the agent a legitimate way to finish without succeeding.
That means two things:
- A "blocked" outcome. Give the agent a tool or response format for "I couldn't do this," with room for what it tried and what it found along the way. Tell it in the instructions that this is an acceptable result, not a failure to hide.
- A soft limit before the hard one. When the run passes most of its budget, tell the agent how much is left and ask it to wrap up: give its best answer or report being blocked. The hard limit then only fires if the agent ignores that nudge.
The soft limit is a request to the model. The hard limit is enforced by your code, outside the model, because a model that's stuck in a loop is exactly the one you can't trust to stop itself.
A minimal loop with a real budget
budget = Budget(max_tokens=200_000, max_seconds=300, max_steps=40)
warned = False
while True:
if budget.exceeded():
return partial_result(history, reason=budget.reason())
if budget.used_fraction() > 0.8 and not warned:
history.append(system_note(
f"About {budget.remaining_pct()}% of the budget is left. "
"Give your best answer now, or call report_blocked "
"with what you tried and what you found."
))
warned = True
reply = model.step(history)
budget.add(tokens=reply.usage.total, steps=1)
if reply.is_final or reply.calls("report_blocked"):
return reply
history += run_tools(reply.tool_calls, timeout=budget.seconds_left())
Notice three details. The budget is checked at the top of the loop, so it covers everything the agent does, not one call. Tool timeouts come from the remaining wall-clock, so one slow tool can't outlive the task. And partial_result returns the history the agent built, so a stopped run still hands back something useful.
Keep your ordinary retry-with-backoff inside run_tools for genuine transient errors. It still has a job. It just isn't the job of stopping the agent.
Picking the numbers
Don't guess, and don't copy someone else's. Run the agent on a set of tasks it handles successfully and record how many tokens and seconds each one used. Set the budget at a comfortable multiple of a typical successful run, say two to three times, so ordinary variation fits and a runaway doesn't.
Then look at the runs that hit the budget. Each is one of two things:
- ✅ A task that was impossible or underspecified. The budget did its job. Check that the agent reported being blocked clearly.
- ❌ A legitimate task that needed more room. Consider raising the budget for that kind of task, rather than raising it for everything.
Different task types can carry different budgets. A quick lookup and a multi-source report shouldn't share one number.
Checklist
- Budget is per task, measured in tokens and wall-clock, with a step cap as backstop
- Checked at the top of the agent loop, not inside individual tool calls
- The agent has an explicit "blocked" outcome and is told it's acceptable
- A soft warning comes before the hard stop, showing what's left
- The hard stop is enforced by code and returns partial results
- Tool timeouts are bounded by the remaining task time
- Limits come from measured successful runs, and budget hits get reviewed
An agent that can only stop by succeeding will eventually meet a task where success isn't possible. Decide ahead of time what that run is allowed to cost, and give it a way to say "I couldn't" before it gets there.