How to Design a Robust Agent Control Loop
Solid agent control loop design comes down to five decisions most people make by accident. Here's how to make them on purpose, with code for the parts that actually break.
class ControlLoop:
def __init__(self, model, tools, max_steps=8, token_budget=40_000):
self.model, self.tools = model, tools
self.max_steps, self.token_budget = max_steps, token_budget
def run(self, goal):
msgs = [sys_msg(), user_msg(goal)]
spent = 0
for step in range(self.max_steps):
reply = self.model.call(msgs)
spent += reply.usage.total
msgs.append(reply.as_message())
if reply.finished:
return reply.answer
if spent > self.token_budget:
return self._wrap_up(msgs) # graceful, not a crash
self._run_tools(reply, msgs)
return self._wrap_up(msgs)Have you ever shipped an agent that worked beautifully in the demo and fell apart the first real week? I have, more than once, and every time the root cause was the same: the control loop was designed by accident. The tool calls got all the attention; the loop that governs them got whatever fell out of the tutorial. Good agent control loop design is really five decisions, and the difference between a reliable agent and a flaky one is whether you made those decisions on purpose or let them happen to you.
The control loop is the scheduler of your agent — it decides when to call the model, when to run a tool, when to stop, and what to do when something breaks. Get it right and a mediocre model behaves. Get it wrong and the best model in the world still flails. Let's make the five decisions explicit.
What Is an Agent Control Loop?
The control loop is the code that surrounds the model and enforces order. The model proposes; the control loop disposes. It owns the transcript, executes tools, applies limits, and handles failure. Strong agent control loop design treats this code as a first-class component with its own tests — not as glue you paste once and forget. The clearest sign a team gets this: their loop lives in its own file with its own test suite, and swapping the underlying model changes nothing about it.
[object Object], ,[object Object],:
,[object Object], ,[object Object],(,[object Object],):
,[object Object],.model, ,[object Object],.tools = model, tools
,[object Object],.max_steps, ,[object Object],.token_budget = max_steps, token_budget
,[object Object], ,[object Object],(,[object Object],):
msgs = [sys_msg(), user_msg(goal)]
spent = ,[object Object],
,[object Object], step ,[object Object], ,[object Object],(,[object Object],.max_steps):
reply = ,[object Object],.model.call(msgs)
spent += reply.usage.total
msgs.append(reply.as_message())
,[object Object], reply.finished:
,[object Object], reply.answer
,[object Object], spent > ,[object Object],.token_budget:
,[object Object], ,[object Object],._wrap_up(msgs) ,[object Object],
,[object Object],._run_tools(reply, msgs)
,[object Object], ,[object Object],._wrap_up(msgs)What this does: gives the loop explicit ownership of step count, token budget, and a graceful wrap-up path, so it always exits on its own terms rather than crashing or looping forever.
Why It Matters
An agent's reliability lives in the loop, not the prompt. You can write a perfect system prompt and still ship an agent that hangs, burns your budget, or returns half-finished work — because those are loop behaviors, not prompt behaviors. The prompt influences what the model wants to do; the loop decides what it's allowed to do.
That split matters for testing. Prompt quality is hard to unit-test. Loop behavior is easy: you can assert that a stuck agent stops within N steps, that a failing tool doesn't crash the run, that the budget is respected. Sound agent control loop design gives you a component you can actually verify.
There's a project-level reason to care, too. The prompt is where you spend your first week on an agent; the loop is where you spend the rest of it. Bugs that survive to production almost always live in loop logic — a stop condition that never fires, an error path that swallows results, a budget check that runs after the expensive call instead of before. Treating the loop as throwaway glue is exactly why so many agents demo well and operate badly.
⚡ Pro tip: Write your loop tests with a fake model that returns scripted replies — always-calls-a-tool, always-errors, never-finishes. You'll catch stop-condition and error-handling bugs in milliseconds, without spending a token on a real API.
Agent Control Loop Design: Stop Conditions and Step Limits
The two most-skipped decisions are how the loop ends.
Design at least two independent stop conditions. The first is success — an explicit finish signal from the model. The second is exhaustion — a hard step cap. Relying on success alone means a confused agent loops until your budget dies; relying on the cap alone means every run wastes the full budget. You want both, and you want them independent.
[object Object], ,[object Object],(,[object Object],):
,[object Object], reply.finished: ,[object Object], ,[object Object],
,[object Object], step >= cfg.max_steps - ,[object Object],: ,[object Object], ,[object Object],
,[object Object], spent >= cfg.token_budget: ,[object Object], ,[object Object],
,[object Object], reply.repeats_last_action(): ,[object Object], ,[object Object],
,[object Object], ,[object Object],What this does: centralizes every exit path in one function returning a labeled reason, so you always know why a run ended — which is the difference between a debuggable agent and a mysterious one.
That fourth condition —
stuckWhy independent conditions? Because they fail independently. If your only exit is "model says done," a model that never says done runs forever. If your only exit is a step cap, every run pays the full cap even when it actually succeeded at step two. Two independent exits mean each covers the other's blind spot — success ends good runs early, exhaustion ends bad runs safely. Fuse them into one clever condition and you reintroduce the single point of failure you were trying to remove.
⚡ Pro tip: Return a reason from your stop check, not just a boolean. Logging "ended: budget" versus "ended: success" across a week of runs tells you instantly whether your caps are too tight or your agent is genuinely inefficient.
Decision 3, 4, 5: Errors, Budgets, and Observability
The remaining three decisions govern what happens between the start and the stop.
Errors should become observations, not exceptions. When a tool throws, catch it and feed the error text back to the model as the observation — the model can often route around a failure it can see. A tool that raises and kills the loop teaches the model nothing.
[object Object], ,[object Object],(,[object Object],):
,[object Object],:
obs = ,[object Object],.tools[call.name](**call.args)
,[object Object], Exception ,[object Object], e:
obs = ,[object Object],
msgs.append(tool_result(call.,[object Object],, obs))What this does: converts a crashing tool into a readable observation, so a single failing call becomes a recoverable step instead of a dead run.
Budgets should be measured in tokens, not just steps — a single step can cost wildly different amounts depending on how much history it carries. Observability means every iteration emits a structured record: step, tool, args, observation size, tokens, latency. Without that trace, tuning an agent is guesswork.
Here's a distinction that saves real money: separate the soft budget from the hard one. At the soft budget, tell the model it's running low and ask it to wrap up — many models will summarize and finish cleanly. At the hard budget, the loop force-stops no matter what. A soft warning before a hard stop turns a truncated, useless run into a complete-enough answer more often than you'd expect.
⚡ Pro tip: Pass the remaining budget into the system prompt as a running number ("~8,000 tokens left"). Models genuinely pace themselves against it — front-loading the important tool calls and skipping nice-to-haves as they watch the tank empty.
⚠️ Common mistake: Handling the happy path only. Most agent loops I review have a clean success branch and nothing for the tool that times out, the model that returns malformed JSON, or the budget that runs dry mid-task. Those aren't edge cases — they're Tuesday. An agent that can't fail gracefully isn't finished; it's a demo. Budget as much design attention to the failure branches as to the success one, and your reliability roughly doubles for free.
⚡ Pro tip: Emit one structured log line per iteration as JSON, not prose. When you later want to know "what's my p95 step count?" or "which tool fails most?", you run a query instead of reading transcripts by hand.
Common Mistakes
Beyond happy-path-only design, three patterns recur. Teams hardcode limits they never revisit — an eight-step cap chosen on day one that's throttling a task that genuinely needs twelve. Teams mix loop logic into tool code, so retry policy lives inside the search tool and step counting lives inside the calculator, making the actual control flow impossible to reason about. And teams catch exceptions but discard the message — turning every tool failure into a bland "an error occurred," which throws away the one thing the model needs to recover. A model that reads "rate limited, retry in 5s" behaves very differently from one that reads "error." Keep the loop's decisions in the loop, and pass real error text through as the observation.
Conclusion
Good agent control loop design isn't exotic — it's five deliberate decisions: how you stop on success, how you stop on exhaustion, how errors become observations, how you budget tokens, and how you observe every step. Make them on purpose and a modest model runs reliably; skip them and no model saves you.
The control-loop skeleton here becomes a template you'll reuse on every agent, tuned per project. I keep mine — the stop-condition function, the error-to-observation wrapper, the structured logger — saved and versioned in PromptABCD alongside the prompts, so a new agent inherits the loop that already earned its reliability instead of starting from a fresh, untested copy.
Continue Reading
Save the prompts from this post
PromptABCD is a free prompt manager. Paste, organize, and reuse your best AI prompts — no more hunting through chat history.
