Minimal Agent Loop with Budgets and Stop Reasons
Write `run_agent(model, tools, task, max_steps, max_tokens, max_consecutive_errors, max_repeats)`. Each step calls `model(transcript)`, which returns `{"type": "final", "content": ...}` or `{"type": "tool", "name": ..., "args": {...}}` plus an optional `usage` token count. Run tools and append their output (or the exception text, flagged `is_error`) to the transcript. Stop with an explicit reason: `final`, `max_steps`, `token_budget`, `repeated_call` (the same tool with identical arguments more than `max_repeats` times) or `tool_errors` (more than `max_consecutive_errors` failures in a row).
The loop itself is ten lines; the engineering is in how it stops. Every unbounded dimension (steps, tokens, identical retries, error streaks) needs a budget, and every exit needs a named reason so dashboards can tell 'answered' from 'gave up' from 'looped'. Tool exceptions become observations instead of crashes, because the model can often recover (fix the argument, pick another tool), but a streak of errors means it cannot. Canonicalising arguments (sorted-key JSON) is what makes loop detection actually fire.
Try it first.
tests.py · 8 passing
tools = {"add": lambda a, b: a + b, "div": lambda a, b: a / b, "search": lambda q: f"results for {q}"}
def solver(tr):
obs = [m for m in tr if m["role"] == "tool"]
if not obs:
return {"type": "tool", "name": "add", "args": {"b": 3, "a": 2}}
return {"type": "final", "content": obs[-1]["content"]}
r = run_agent(solver, tools, "2+3?")
assert (r.stop_reason, r.output, r.steps) == ("final", "5", 2)
r = run_agent(lambda tr: {"type": "tool", "name": "search", "args": {"q": "x"}}, tools, "loop")
assert r.stop_reason == "repeated_call" and r.steps == 3
r = run_agent(lambda tr: {"type": "tool", "name": "search", "args": {"q": len(tr)}}, tools, "wander", max_steps=5)
assert r.stop_reason == "max_steps" and r.steps == 5
r = run_agent(lambda tr: {"type": "tool", "name": "div", "args": {"a": len(tr), "b": 0}}, tools, "fail")
assert r.stop_reason == "tool_errors" and r.steps == 3
assert all(m["is_error"] for m in r.transcript if m["role"] == "tool")
r = run_agent(lambda tr: {"type": "tool", "name": "search", "args": {"q": len(tr)}, "usage": 4000}, tools, "spend")
assert r.stop_reason == "token_budget" and r.steps == 3
def recovers(tr):
obs = [m for m in tr if m["role"] == "tool"]
if not obs:
return {"type": "tool", "name": "nope", "args": {}}
if obs[-1]["is_error"]:
return {"type": "tool", "name": "div", "args": {"a": 6, "b": 3}}
return {"type": "final", "content": obs[-1]["content"]}
r = run_agent(recovers, tools, "recover")
assert (r.stop_reason, r.output) == ("final", "2.0")
assert "LookupError" in r.transcript[2]["content"]What changes in production? Persist the transcript after every step so a crashed worker resumes instead of re-running side effects, give each tool call an idempotency key, add wall-clock and dollar budgets, and compact or summarise old observations when the transcript nears the context limit. Measure the stop-reason distribution per release: a rise in `max_steps` or `repeated_call` is the earliest regression signal you will get.