What an agent actually is
By the end: You can draw the agent loop and explain every step of it.
- The loop, in five lines
- Why it lives in your code
- Writing one without a framework
- When an agent is the wrong answer
New to the agent loop? Agent is a loaded word for something small. Call the model; if the reply asks to use a tool, run the tool, add the result to the conversation, and call the model again. Keep going until it stops asking or until you decide it has had enough turns. That loop, a few dozen lines of ordinary code, is the agent — there is no other machinery hiding behind the word. Why it matters
The word 'agent' is used loosely enough that people arrive expecting something exotic. This section takes it apart and builds one from nothing, so that you can see which part is the model and which part is your own code, a distinction that decides where every bug you meet later actually lives. It also covers the decision that comes first: whether the task in front of you needs an agent at all, which is where most projects go wrong.
A loop, not a mystery#
An agent is a while loop around a model call. Call the model. If it asked for tools, run them and add the results. Call it again. Stop when it stops asking or when you run out of budget.
1. Send messages and tool definitions
2. Did the model request a tool?
no -> done, return its answer
yes -> run the tools, append the results, go to 1That is the whole idea. Everything else in agent engineering is about what you do around that loop: budgets, error handling, deciding what stays in the context.
Write it yourself once#
def run(task: str, max_turns: int = 10) -> str:
messages = [{"role": "user", "content": task}]
for turn in range(max_turns):
response = client.chat(messages, tools=TOOLS)
messages.append(response.message)
if response.stop_reason != "tool_use":
return response.text # model is done
for call in response.tool_calls:
result = run_tool(call)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result),
})
return "Stopped: reached the turn limit."Why write it by hand
Interviewers ask you to reconstruct this loop and defend the decisions in it. Candidates who have only used a framework can describe what it does and not why. Twenty minutes writing it yourself is the difference.
The same loop, actually running#
Reading a loop and watching one are different. Everything in this topic runs against a scripted stand-in for the model rather than a hosted one, so the loop, the failures in it and the guards against them all execute offline and print the same numbers on every run. The stub takes a list of responses and returns them in order, which is exactly what a real provider does, minus the uncertainty about which response you will get.
import json
def find_order(customer):
return {"order_id": "A-4471", "total_cents": 2900}
def refund(order_id):
return {"refunded": order_id}
TOOLS = {"find_order": find_order, "refund": refund}
class StubModel:
"""A scripted stand-in for a hosted model, so the loop runs offline and
prints the same thing on every run. One script entry per response."""
def __init__(self, script):
self.script, self.calls = list(script), 0
def chat(self, messages, tools=None):
self.calls += 1
return self.script[min(self.calls, len(self.script)) - 1]
def run(model, task, max_turns=6):
messages = [{"role": "user", "content": task}]
transcript = []
for turn in range(max_turns):
reply = model.chat(messages, tools=TOOLS)
messages.append({"role": "assistant", "content": reply})
if reply["stop_reason"] != "tool_use":
return reply["text"], transcript
for call in reply["tool_calls"]:
result = TOOLS[call["name"]](**call["args"])
transcript.append((turn, call["name"], call["args"], result))
messages.append({"role": "tool", "content": json.dumps(result)})
return "stopped: reached the turn limit", transcript
SCRIPT = [
{"stop_reason": "tool_use",
"tool_calls": [{"name": "find_order", "args": {"customer": "ada"}}]},
{"stop_reason": "tool_use",
"tool_calls": [{"name": "refund", "args": {"order_id": "A-4471"}}]},
{"stop_reason": "end_turn", "text": "Refunded order A-4471."},
]
answer, transcript = run(StubModel(SCRIPT), "Refund Ada's most recent order.")
for turn, name, args, result in transcript:
print(f"turn {turn}: {name}({args}) -> {result}")
print("answer:", answer)
# turn 0: find_order({'customer': 'ada'}) -> {'order_id': 'A-4471', 'total_cents': 2900}
# turn 1: refund({'order_id': 'A-4471'}) -> {'refunded': 'A-4471'}
# answer: Refunded order A-4471.Every tool call is untrusted input#
The loop above indexes straight into the tool table and splats the arguments in. That works for exactly as long as the model emits the call you imagined. It will not: it will misname a key, drop a required argument, or ask for a tool that does not exist, usually because something similar existed in its training data. None of those are exotic and none of them should end the run.
BAD_CALLS = [
{"name": "refund", "args": {"order_id": "A-4471"}}, # fine
{"name": "refund", "args": {"order": "A-4471"}}, # wrong key
{"name": "refund", "args": {}}, # nothing at all
{"name": "refund_ord", "args": {"order_id": "A-4471"}}, # invented tool
]
def dispatch_naive(call):
return TOOLS[call["name"]](**call["args"])
def dispatch(call):
"""Never raises. Every outcome is something the model can read and act on."""
fn = TOOLS.get(call["name"])
if fn is None:
return {"ok": False, "error": "no tool named " + call["name"]
+ ". Available: " + ", ".join(sorted(TOOLS))}
try:
return {"ok": True, "result": fn(**call["args"])}
except TypeError as e:
return {"ok": False, "error": "bad arguments: " + str(e)}
except Exception as e:
return {"ok": False, "error": type(e).__name__ + ": " + str(e)}
survived = 0
for call in BAD_CALLS:
try:
dispatch_naive(call)
survived += 1
except Exception as e:
print("naive loop died:", type(e).__name__)
print("naive dispatcher survived", survived, "of", len(BAD_CALLS), "calls")
print()
for call in BAD_CALLS:
print(dispatch(call))
# naive loop died: TypeError
# naive loop died: TypeError
# naive loop died: KeyError
# naive dispatcher survived 1 of 4 calls
#
# {'ok': True, 'result': {'refunded': 'A-4471'}}
# {'ok': False, 'error': "bad arguments: refund() got an unexpected keyword argument 'order'"}
# {'ok': False, 'error': "bad arguments: refund() missing 1 required positional argument: 'order_id'"}
# {'ok': False, 'error': 'no tool named refund_ord. Available: find_order, refund'}That is the rule for errors inside a tool generally, not just argument errors. A database timeout, a 404, a rate limit: catch it, put the reason in the tool result, and let the model decide what to do. The one distinction worth making is between an error the model can act on and one it cannot. A missing argument is actionable. A payment provider being down is not, and telling the model about it just invites it to try again with the same arguments, which is why that class of error belongs to your retry policy rather than to the model.
Designing a tool the model can call correctly#
You cannot stop a model emitting a bad call, but you can decide what a bad call does. Most of that is decided by the tool signature, before the model is involved. Three rules cover most of it: no default that silently acts, the unit in the parameter name, and closed sets instead of free strings. The demonstration is a partial refund, where the customer is owed 9.00 on an order worth 29.00.
ORDERS = {"A-4471": {"total_cents": 2900, "status": "shipped"}}
WANT = 900 # the customer is owed 9.00, not the whole order
def refund_loose(order_id, amount_cents=None):
"""Defaults that act. Omitting the amount refunds everything."""
amount = ORDERS[order_id]["total_cents"] if amount_cents is None else amount_cents
return {"ok": True, "refunded_cents": amount}
def refund_strict(order_id, amount_cents):
"""No default, one unit in the name, and the value checked against the order."""
if not isinstance(amount_cents, int):
return {"ok": False, "error": "amount_cents must be a whole number of cents, "
"got " + repr(amount_cents)}
total = ORDERS[order_id]["total_cents"]
if not 0 < amount_cents <= total:
return {"ok": False, "error": f"amount_cents must be between 1 and {total}"}
return {"ok": True, "refunded_cents": amount_cents}
EMITTED = [
{"order_id": "A-4471", "amount_cents": 900}, # what you hoped for
{"order_id": "A-4471"}, # forgot the amount
{"order_id": "A-4471", "amount_cents": 9.0}, # dollars, not cents
]
for label, fn in (("loose", refund_loose), ("strict", refund_strict)):
for args in EMITTED:
try:
out = fn(**args)
except TypeError as e:
out = {"ok": False, "error": "bad arguments: " + str(e)}
verdict = ("correct" if out.get("refunded_cents") == WANT
else "REJECTED" if not out["ok"]
else "SILENTLY WRONG: refunded " + str(out["refunded_cents"]))
print(f"{label:<7}{str(args):<50}{verdict}")
# loose {'order_id': 'A-4471', 'amount_cents': 900} correct
# loose {'order_id': 'A-4471'} SILENTLY WRONG: refunded 2900
# loose {'order_id': 'A-4471', 'amount_cents': 9.0} SILENTLY WRONG: refunded 9.0
# strict {'order_id': 'A-4471', 'amount_cents': 900} correct
# strict {'order_id': 'A-4471'} REJECTED
# strict {'order_id': 'A-4471', 'amount_cents': 9.0} REJECTEDNotice what the strict version did not do: it did not make the model more accurate. Both signatures received the same three calls. What changed is that a wrong call became visible and correctable instead of a silent refund of the wrong amount. That is the whole of tool schema design.
- One tool, one job. A do_everything tool with a mode argument moves the decision into free text, where you cannot validate it.
- Name the unit and the type in the parameter: amount_cents, not amount. Days, not duration.
- No default that acts. A default that reads is fine; a default that refunds, deletes or sends is not.
- Closed sets over free strings. status must be one of shipped, refunded, cancelled, and the error should list them.
- Return little. Whatever a tool returns is resent on every later turn, which is the context problem in the section after next.
- Describe the tool for someone who has not seen your codebase, and say when not to use it. The description is prompt, and it is read every turn.
When not to build an agent#
Agents are slow, expensive and harder to debug than a single call. They earn that cost only when the steps genuinely cannot be decided in advance.
- One model call: classification, extraction, summarising, rewriting.
- A fixed pipeline you control: retrieve, then answer. You know the steps, so hard-code them.
- An agent: the number and order of steps depend on what is found along the way.
The middle row hides the interesting case, which is a pipeline with a decision in it. Branching on the result of a step does not make something an agent. The difference is who enumerated the paths: in a pipeline you did, in advance, and there are as many as you wrote; in an agent the model does, at run time, and there is no upper bound on them except the one you impose. Costing that out on the same set of tasks makes the trade concrete.
# Each task needs some number of lookups before the answer is knowable. You do
# not know that number in advance; that is the whole difficulty.
DEPTHS = [1, 1, 1, 2, 1, 3, 8]
MAX_CALLS = 6
def pipeline(depth):
"""Look up once, then answer. Two model calls, always."""
return 2, depth <= 1
def pipeline_with_a_decision(depth):
"""Look up; if that missed, look up in the other system; then answer."""
return min(depth, 2) + 1, depth <= 2
def agent(depth):
"""Keep going until the model stops asking, or the cap trips."""
calls = min(depth + 1, MAX_CALLS)
return calls, depth + 1 <= MAX_CALLS
print(f"{'strategy':<26}{'model calls':>12}{'solved':>9}")
for name, fn in (("pipeline", pipeline),
("pipeline with a decision", pipeline_with_a_decision),
("agent", agent)):
runs = [fn(d) for d in DEPTHS]
calls = sum(c for c, _ in runs)
solved = sum(ok for _, ok in runs)
print(f"{name:<26}{calls:>12}{solved:>6}/{len(DEPTHS)}")
print("worst case calls: pipeline 2, decision 3, agent", MAX_CALLS, "(the cap)")
# strategy model calls solved
# pipeline 14 4/7
# pipeline with a decision 17 5/7
# agent 21 6/7
# worst case calls: pipeline 2, decision 3, agent 6 (the cap)Read the last two columns together rather than separately. The agent solves the most tasks and it is the only one of the three whose cost you cannot state before you run it, which is why its worst case is a number you chose rather than a number the design gives you. It also fails the deepest task after spending six calls, where the pipeline fails it after two. That asymmetry is the honest case against agents: you pay the full price on the runs that do not work as well as the ones that do.
So the question to ask of a task is not whether it is complicated but whether you can write the paths down. If you can, write them down; you get a cheaper system with a knowable worst case and a stack trace when it breaks. Saying this out loud in an interview is a strong signal. Reaching for an agent when a pipeline would do is the most common design mistake in this area.
Where people go wrong
Building an agent because the task sounds sophisticated. If you can write down the steps in advance, write them down in code, and you will get something faster, cheaper and testable.
If this comes up in an interview
What is an agent, in one sentence?
A loop that calls a model, executes any tools the model requests, feeds the results back, and repeats until the model stops asking or a budget runs out.
When would you not use an agent?
When the steps are known in advance. A fixed pipeline is cheaper, faster and far easier to debug. Agents earn their cost only when the path depends on what is discovered along the way.
Then they push further
Give an example of a task that looks like it needs an agent but does not.
Summarising a support ticket and routing it to a team. It feels open-ended, but the steps are fixed: classify, summarise, route. Two calls with structured output do it cheaper, and every step can be tested on its own. An agent would add variance and cost for no gain.
And one that genuinely does?
Working out why a customer's invoice is wrong across billing, usage and plan history, where what to look up next depends on what the last lookup showed. The number and order of steps cannot be written down in advance.
What gets marked up. Strong answers name what agency costs: variance, latency, money and harder debugging, and treat an agent as something a task has to earn. Weak answers treat agents as the default modern architecture.
Where does the loop run?
In your code. The model cannot execute anything or call itself. It only emits a structured request, and your program decides whether to act on it and what to send back.
Build this: Write the loop above from scratch with one tool and a turn limit. No framework. Then give it a task it cannot finish and confirm it stops cleanly.
Watch it explained
This is one section of The agent loop, 14 hours across 4 sections. Read it in full to get the whole topic in order.
Stop 16 of 50 on the AI Engineer route
0 of 50 topics done · 0h behind you, 603h to go
What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.