takeaway notes
Agents in Production: Why They Loop
After this you can explain why an agent, a model using tools in a loop, keeps going until the model decides it is done, and why that loop can repeat itself, grow its context and bill, and stack up small errors. You can write tool errors the model can act on, and add the guardrails production needs: a maximum number of steps, a token or dollar budget, timeouts, loop detection, idempotent tools, human approval for risky actions, a kill switch, and traces with alerts.
Concepts
The loop
Theory
An agent is a model, using tools, in a loop. You send a goal and a list of tools; the model replies with an answer or a tool call; your code runs the tool and sends the result back, and the model decides again. The loop ends when the model replies with no tool call, so the model decides when the work is finished. Nothing in that loop says stop after ten tries unless you add it.
Practical example: The whole loop
Your code is the only adult in the room: a loop with no limit is a billing meter with no off switch.
def call_model(messages): # stub: returns a tool call or a final answer
if len(messages) < 3:
return {"tool": "search_files", "args": {"q": "report"}}
return {"tool": None, "text": "Done."}
def run_tool(reply): # stub: your code runs the tool
return "no files found"
messages = [{"role": "user", "content": "Find the report."}]
while True: # note: nothing here says stop after ten tries
reply = call_model(messages)
if reply["tool"] is None: # no tool call: the model decided it is done
break
messages.append({"role": "tool", "content": run_tool(reply)})
print(reply["text"])Stuck loops
Theory
Agents often loop because the last result didn't change the model's mind: it sees the same history plus one more failure, so the same move looks reasonable. Other loops are sneakier: ping-pong, where step A undoes step B, or a query rephrased each time so no two calls match exactly. Nothing throws an exception; agent frameworks even ship a dedicated error for running out of steps before hitting a stop condition.
Practical example: Same query, same nothing
The agent searches for a file and gets nothing, so it decides to search again with the same query. Your logs just show a very busy agent.
The token bill
Theory
The model has no memory between calls, so every turn you resend the whole conversation: the prompt, every tool call and every tool result so far. Context only grows, and each turn sends more than the one before. Big tool results are the real culprit: one verbose log dump can add thousands of tokens, permanently.
Practical example: Twenty turns of a failed task
If each turn adds a thousand tokens, turn ten sends about ten thousand and twenty turns add up to about two hundred ten thousand input tokens (illustrative, ignoring prompt caching, which makes repeated history cheaper). First fix: keep tool outputs small.
added_per_turn = 1000 # illustrative; no prompt caching
turns = 20
sent = [added_per_turn * t for t in range(1, turns + 1)] # each turn resends all
print(sent[9], sent[19]) # turn 10: 10000, turn 20: 20000
print(sum(sent)) # 210000 input tokens in totalCompounding errors
Theory
One small mistake early, and every later step builds on it. The agent doesn't know which step was wrong, and it can treat its own earlier guess as fact. Shorter chains win: fewer steps, clearer tools, and a check between steps.
Practical example: Ninety percent, ten times
If each step succeeds ninety percent of the time, ten steps in a row are all right only about thirty-five percent of the time (assuming independent steps), so roughly two in three runs contain a mistake. Verify with the world, not the model: check that the file exists.
step_success = 0.9 # each step right 90 percent of the time
steps = 10
all_right = step_success ** steps # assumes independent steps
print(round(all_right, 2), round(1 - all_right, 2)) # 0.35 0.65Tool errors
Theory
When a tool fails, the error goes back to the model as the next message, so your error text decides what happens next. A stack trace or just the word failed leaves the model guessing, and a common guess is the same call again. Write errors for a stranger with no context, and mark them as errors, since tool-calling APIs can flag a result as an error so it isn't mistaken for data.
Practical example: An error the model can act on
Instead of failed, return: rate limit hit, retry after sixty seconds. A denied action is feedback too: say no, and why, so the model picks another path.
Hard limits
Theory
Rule one: a maximum number of steps, always, in production; agent-building guides list it as a standard stopping condition. Many agent SDKs have this setting, but defaults differ by framework and version and some have no limit unless you set one. Rule two: a token or dollar budget per run; spend is checked after each response, so a run can overshoot by about one call. Rule three: timeouts on every tool call and on the whole run.
Practical example: A step cap that fails loudly
Count the turns; at the cap, stop, return what you have plus a clear status like out of turns, and save progress so the run can resume.
MAX_STEPS = 10
def call_model(messages): # stub: an agent that never decides it is done
return {"tool": "search_files", "args": {"q": "report"}}
messages, status = [], "done"
for step in range(MAX_STEPS):
reply = call_model(messages)
if reply["tool"] is None:
break
messages.append({"role": "tool", "content": "no files found"})
else:
status = "out_of_turns" # fail loudly, keep the progress
print(status, len(messages))Smart guards
Theory
Limits stop the loop only after you've paid for it; smarter guards catch it earlier. Loop detection fingerprints each tool call by name plus arguments and reacts when the same fingerprint repeats. Idempotent tools, where calling twice has the same effect as calling once, make retries safe: give each action a unique key, and the server returns the first result on a repeat.
Practical example: Nudge, then escalate
On a repeat, tell the model it made this exact call before. If it still repeats, stop the run and hand it to a human with the trace attached.
import json
from collections import Counter
seen = Counter()
def check_repeat(tool, args, limit=3):
fingerprint = tool + json.dumps(args, sort_keys=True) # name plus arguments
seen[fingerprint] += 1
if seen[fingerprint] >= limit:
return "escalate" # stop the run, hand it to a human with the trace
if seen[fingerprint] > 1:
return "You made this exact call before. Try something different, or give up."
return None
for _ in range(3):
print(check_repeat("search_files", {"q": "report"}))Humans and traces
Theory
Some actions need a human: sending money, deleting data, emailing your whole customer list; agents can pause for human feedback at checkpoints. Add a kill switch, a flag the loop checks every turn that stops every run without a deploy. Trace every turn (input, tool call, result, tokens, time) and alert on too many steps, unusual cost per run, or the same tool called again and again.
Practical example: A simple permission rule
Read-only tools run free. Write tools ask. Irreversible tools ask twice. Save the bad traces: every looping run is a free test case for your evals.
Checklist
- Add a maximum step count to every agent loop, and check whether your SDK's limit is off by default.
- Add a per-run token or dollar budget with headroom, plus timeouts on each tool call and on the whole run.
- Truncate, paginate or filter large tool outputs, and tell the agent how to ask for more.
- Rewrite tool error messages to say what went wrong and what to try instead, and flag them as errors.
- Fingerprint tool calls (name plus arguments) to detect repeats, nudge the model, then escalate to a human.
- Give side-effecting tools an idempotency key, and require human approval for anything you can't undo.
- Add a kill-switch flag checked every turn, trace every turn, and alert on step count, cost per run and repeated calls.
Gotchas
- Trusting the model to decide when it's done. Your code owns the exit: step caps, budgets and timeouts.
- Assuming your agent SDK has a step limit. Some have none unless you set one, so check yours.
- Returning a stack trace or just failed from a tool. Return what went wrong and what to try instead.
- Asking the model whether a step worked. Verify with the world: check that the file exists.
- Letting a tool dump a whole log into the context. It is resent every turn; keep outputs small.