An agent is made of five parts, and every framework I have come across recombines them without adding a sixth: the instructions (the mandate), the tool catalogue (the capabilities), the state (what has already happened), the decision engine (the model, which at every turn chooses whether to speak or to act) and the termination condition (when to stop). When an agent misbehaves, the defect is always in one of these five, and being able to say which one is the difference between fixing it in ten minutes and spinning your wheels for two days.
The analogy: the new hire on day one
Imagine handing a task to someone who has just joined the company, extremely sharp and with no memory of the company itself. You have to give them four things: the description of the task with its limits ("you may refund up to 50 euros, above that ask me"), the list of systems they can access with the credentials, a notebook where they write down what they have already done, and the rule that at the end of the day they stop even if they have not finished. How sharp they are is the model and you do not control it; the other four things are all yours, and they are the four where the problems nest.
The loop, in nine lines
Let us start from the skeleton. This is the agent stripped to the bone, framework-agnostic. It is a pattern to read, not executed: schemas_of, execute and TurnLimit are placeholder names that further down, in the complete version, become real, executed code.
def loop(model, instructions, tools, request, max_turns):
state = [{"role": "system", "content": instructions},
{"role": "user", "content": request}]
for _ in range(max_turns):
reply = model(state, schemas_of(tools)) # 1. decide
state.append(reply)
if not reply.calls: # 2. finished?
return reply.text
for c in reply.calls: # 3. act
state.append(execute(tools, c)) # 4. record
raise TurnLimit(max_turns) # 5. safety stop
The numbered comments give the order that matters. First you decide (1), then you check whether the model asked for anything (2): if it asked for nothing, the answer is text and the loop ends. Otherwise you execute every request (3) and append the result to the state (4), which the next turn sends back to the model. The for with max_turns is the safety exit (5): without it the loop would be a while True at the mercy of the model.
Every agentic framework, from the lightest to the most verbose, is this with extras on top: typing, retries, tracing, guardrails, handling of parallelism and serialisation of the state. Useful, sometimes indispensable — but if you have these nine lines in mind, no abstraction will look mysterious any more.
The loop as a vocabulary: the four phases and how they break
In the previous lesson we drew the loop as perception → decision → action → observation. It is not a decorative formula borrowed from robotics: it is the vocabulary with which a failure is located. Each phase has a characteristic way of breaking, and the symptom you see on the surface is different for each. It is worth walking through them one by one, because this is the content you will reuse in all the following lessons.
Perception is what the model has in front of it when it decides: the instructions, the history, the tool results, and the descriptions of the available tools. It breaks through absence or through excess. Through absence: the model does not see a piece of information that you believe it knows — today's date, the user's name, the fact that it already called a tool. Through excess: the context holds so much material that the useful signal gets lost. This is the phenomenon documented by Liu and colleagues (Lost in the Middle: How Language Models Use Long Contexts, arXiv:2307.03172), whereby information placed in the middle of a long context is used worse than information at the beginning or at the end. The typical symptom of a perception failure is an agent that "ignores" something you told it: often it does not ignore it, it does not see it — or it sees it buried among three thousand tokens of tool results.
Decision is the choice between speaking and acting, which tool, and with which arguments. It breaks in three distinct ways, described below in the part on the decision engine, and the important thing is that they have different cures: an error of "speak or act" is cured in the instructions, an error of "which tool" is cured in the catalogue, an error of "which arguments" is cured in the schema. Mixing the three cures is the most effective way of never understanding what worked.
Action is the only phase that does not involve the model: your code carries it out. That is why it is also the only one that can be fully tested with ordinary tools, and the only one where you have guarantees. It breaks like any function breaks — exceptions, timeouts, unexpected data — but with a complication of its own: the arguments come from a text generator, so it is a system boundary with untrusted input. Keeping this phase small, validated and free of surprises is the investment with the best return in all the work on an agent.
Observation is the return of the result into the state. It is the most neglected phase and the one that produces the most confusing bugs. If the result is not appended, the model calls the tool forever. If it is appended in the wrong place, the endpoint rejects the request with a validation error that says nothing about your bug. If it is appended as prose instead of as structure, the model has to re-interpret it and sometimes interprets it badly. And if the result is huge, you have moved the problem into the perception phase of the next turn — because what you observe today is what you perceive tomorrow, and you pay for it at every remaining turn.
This last point deserves to be stated explicitly, because it is the connection nobody sees: observation and perception are the same queue seen from two sides. Every byte a tool returns enters the context and is sent back to the model at every following turn. A tool that returns, say, 3000 tokens of JSON does not just have a local cost: it has a cost that repeats, and at the same time it worsens the quality of perception in the turns to come. This is the technical, not stylistic, reason why a tool must be an information reducer.
Instructions that work: what goes in and what does not
The system prompt is the only place where behaviour is defined persistently, and for that very reason it is the place where rubbish accumulates. After a few weeks of adjustments, a typical system prompt contains five useful rules, three contradictory ones and two that describe a problem solved long ago. It is worth having a criterion for what deserves to be in it.
What goes in is what holds at every turn. The instructions are sent back n times in a run of n turns. So: a line that prevents a recurring error is an excellent investment, a line about a rare case is a fixed cost paid for an occasional benefit. For rare cases there is a better place: the description of the relevant tool, which the model reads at the moment when that case matters.
The completion criterion goes in, written in a verifiable way. "You are done when you have produced a list of SKUs with the quantity" can be checked; "help the user in the best possible way" can be neither checked nor violated, so it constrains nothing. A criterion that cannot be verified is not a weak instruction: it is noise that you pay for.
Prohibitions go in with the alternative. "Do not call send_order above 1000" leaves the model without a road; "do not call send_order above 1000: use request_approval" gives it one. A prohibition with no alternative produces agents that get stuck or that improvise, and improvising is the thing you want to avoid.
Nothing that changes goes in. Today's date, user name, price list, balance: all things that go stale. A prompt that contains yesterday's date is a prompt that worked yesterday, and the failure it produces is particularly insidious because it is intermittent. For each of these things there is a three-line tool.
Long examples do not go in, unless they are needed. A few examples in the prompt (the few-shot technique) are powerful for fixing a format, and cost a multiplier on every turn. Before adding three examples of two hundred tokens, it is worth checking whether a precise sentence achieves the same effect.
One last practical recommendation, which sounds like bureaucracy and pays off immediately: keep the system prompt in a versioned file, not in a string inside the code, and note next to each rule why it is there. In a month that line will look useless to you, and without the why you will remove it — reopening the bug it had closed.
The state up close: the exact sequence of messages
Before talking about design, you have to see what the list looks like. This is the complete state of a run that made two turns. I printed it as it is, because almost all of an agent's bugs can be read here.
The same state, in the form of data. I did not write it by hand: it is printed by the complete script of Part 5, with a fake model that follows a script (two tool requests and a final answer), one JSON line per message. The format is that of chat completions-style APIs, in which the tool result carries tool_call_id; Ollama's native API instead uses the tool_name field, as in the previous lesson. The exact format changes from one provider to another, so check it against the documentation of yours.
for m in state:
print(json.dumps(m, ensure_ascii=False))
# {"role": "system", "content": "Decide whether to reorder. Read the data with the tools..."}
# {"role": "user", "content": "Should SKU-11 be reordered?"}
# {"role": "assistant", "content": null, "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "read_stock", "arguments": "{\"sku\": \"SKU-11\"}"}}]}
# {"role": "tool", "tool_call_id": "c1", "content": "{\"stock\": 4, \"threshold\": 10}"}
# {"role": "assistant", "content": null, "tool_calls": [{"id": "c2", "type": "function", "function": {"name": "read_incoming_orders", "arguments": "{\"sku\": \"SKU-11\"}"}}]}
# {"role": "tool", "tool_call_id": "c2", "content": "{\"incoming\": 20}"}
# {"role": "assistant", "content": "DO NOT REORDER: 4 in stock but 20 incoming."}
Three details that make the difference between code that works and code that gives incomprehensible errors. The tool_calls[].id field is the correlation identifier: the tool message repeats it in tool_call_id, and that is how the model knows which result belongs to which request. If the model asks for two tools at once and you answer without that id (or with the wrong id), the pairing is lost. The arguments field is a JSON string, not an object: it has to be deserialised, and it may contain malformed JSON if the model got it wrong, so that json.loads goes inside a try (in the script, execute does it). And the content of the tool message is text: whatever structure you return is serialised, so you might as well serialise it yourself with meaningful keys.
Part 1: the instructions are a specification, not a wish
The instructions have already been discussed above; here the point is to put them in the form of a specification. A useful text says four things in a verifiable way: the goal with its completion criterion, the boundaries (what never to do, and what to do by delegating: "do not call send_order for amounts above 1000, use request_approval"), the tool-use policy ("never estimate a stock level: read it; if the tool returns an error, say so, do not guess") and the format of the final answer. Even when you use structured output (Structured output), writing the format in words reduces drift. The test is simple: for each sentence, ask yourself whether you could look at a run and say whether the agent respected it. If not, the sentence constrains nothing.
Part 2: the tool catalogue is the agent's surface
Of the outside world the model knows only what the catalogue describes, and three design rules follow from this. The first is granularity: one tool per intention, not per endpoint, because find_customer(name|email|vat_number) is better than three nearly identical tools (the model errs less in choosing and you take up less context). The second: names and descriptions are prompts; q is an argument that the model will fill badly, text_to_search is not, and the description says when to use the tool, not what it does internally. The third: the result must be designed, because a tool that returns thousands of tokens drowns the context, while one that returns the five fields that are needed avoids turns and cost; the tool is an information reducer, not a pipe. I have not measured the effect on the number of turns: it is the reasoning about context made above, not an experimental result.
The dedicated series goes deeper into the practical side in advanced tool calling: APIs, web and custom tools; in the lesson Tools: function calling, JSON schemas and errors we instead go inside the JSON schema and error handling.
Part 3: the state, that is, the bottleneck
The state is a list of messages of four types: system, user, the assistant's replies (including tool requests) and the tool results. Three properties to have clear:
- It is append-only during the run. You do not rewrite the past, you add to it.
- Everything is sent again, every time. At turn n you pay for the sum of all the messages up to n. Hence the quadratic growth of the cost: with messages of similar size, 10 turns cost about 55 units, not 10.
- Request-result pairs must be kept together. If you truncate the history and leave a tool request without its result, many endpoints reject the call with a validation error. It is one of the most tedious bugs to diagnose: see Conversational memory.
The second property is the one that costs. If each turn adds about the same amount of text, the total paid after n turns is the sum 1 + 2 + … + n, which grows like n². With the script of Part 5 I computed it for ten turns: 1, 3, 6, 10, 15, 21, 28, 36, 45, 55 cumulative units. Ten turns therefore cost 55 units and not 10, and that is under an assumption of constant size: in reality tool results weigh more than system messages, and the curve is even steeper.
Part 4: the decision engine, and what it really decides
At every turn the model makes three nested choices: speak or act; if acting, which tool; and with which arguments. They are three distinct points of failure and they are diagnosed differently.
Does it get "speak or act" wrong (answers without checking)? The problem is in the instructions or in the tool descriptions. Does it get "which tool" wrong? The catalogue has overlapping boundaries or ambiguous names. Does it get "which arguments" wrong? The schema is too permissive, or the previous tool did not give it the data it needed. Three different cures for three different failures, which is why it pays to be able to tell them apart before touching anything.
There is a parameter that often solves the first case better than a thousand words of prompt: the tool choice policy. Some function-calling APIs accept a field, tool_choice, which can be "auto", "none" or "required" (the OpenAI Agents SDK exposes it in ModelSettings). Forcing "required" on the first turn stops an agent from answering off the cuff before it has looked at the data. Careful, though: the OpenAI compatibility page of Ollama (consulted on 2026-11-12) lists tool_choice among the unsupported fields, so with a local model served by Ollama you have to obtain the same effect another way, for example by checking in your loop that the first turn contains a tool request.
Part 5: termination, in four forms
This is where prototypes are separated from systems. An agent in production has four exits, not one:
- Success. The model answers with text (or with the requested structured object) instead of asking for tools.
- Turn limit. The hard ceiling. Below it, the agent is cut off mid-thought, so the exception handling must return something sensible to the user, not a stack trace.
- Budget limit. Tokens or euros accumulated in the run. It is the brake you need when the number of turns is low but each turn is enormous.
- External interruption. A guardrail fired, an overall timeout, a cancellation by the user.
Let us put all four into the skeleton, because the complete version is the one you will end up using. First the support code, which makes the block truly executable: the warehouse and the two tools (the same as in the previous lesson), execute, which deserialises the arguments and turns a tool exception into a message the model can read, and Reply, the shape in which a model (real or fake) returns a turn. They are here so that every name in the initial skeleton has a body.
import json
import time
class TurnLimit(Exception):
pass
class Outcome:
def __init__(self, text, reason, turns, tokens):
self.text, self.reason, self.turns, self.tokens = text, reason, turns, tokens
class Reply:
"""What the model returns for one turn."""
def __init__(self, message, tokens_used):
self.message = message
self.tokens_used = tokens_used
self.calls = message.get("tool_calls") or []
self.text = message.get("content")
WAREHOUSE = {"SKU-11": {"stock": 4, "threshold": 10, "incoming": 20}}
def read_stock(sku):
r = WAREHOUSE[sku]
return {"stock": r["stock"], "threshold": r["threshold"]}
def read_incoming_orders(sku):
return {"incoming": WAREHOUSE[sku]["incoming"]}
TOOLS = {"read_stock": read_stock, "read_incoming_orders": read_incoming_orders}
def schemas_of(tools):
return [{"type": "function", "function": {"name": n}} for n in tools]
def execute(tools, call):
f = call["function"]
try:
result = tools[f["name"]](**json.loads(f["arguments"])) # arguments is a JSON STRING
except Exception as e: # tool error: the model reads it
result = {"error": f"{type(e).__name__}: {e}"}
return {"role": "tool", "tool_call_id": call["id"],
"content": json.dumps(result, ensure_ascii=False)}
Then the real loop, with the four exits. The deadline and budget check happens before calling the model: this means the budget can be exceeded by one call (the last), and that a turn already launched is not interrupted. If you need a hard ceiling, the limit must also be put on the single call (the endpoint's max_tokens). I also return the state, because it is what you will want to inspect.
def loop(model, instructions, tools, request,
max_turns=8, max_tokens=20_000, deadline_s=60.0):
state = [{"role": "system", "content": instructions},
{"role": "user", "content": request}]
tokens = 0
start = time.monotonic()
for turn in range(1, max_turns + 1):
if time.monotonic() - start > deadline_s:
return Outcome(None, "deadline", turn, tokens), state
if tokens > max_tokens:
return Outcome(None, "budget", turn, tokens), state
reply = model(state, schemas_of(tools))
tokens += reply.tokens_used
state.append(reply.message)
if not reply.calls:
return Outcome(reply.text, "success", turn, tokens), state
for c in reply.calls:
state.append(execute(tools, c))
return Outcome(None, "max_turns", max_turns, tokens), state
Finally the fake model: asks builds an assistant message with a tool request, scripted_model returns at every turn the next reply from a list, with a fixed token count (100, invented) so that the budget can be tried out. This way I try the four exits without calling a model (Python 3.14.4, two identical runs, output below).
def asks(id_, name, sku):
return {"role": "assistant", "content": None,
"tool_calls": [{"id": id_, "type": "function",
"function": {"name": name, "arguments": json.dumps({"sku": sku})}}]}
def scripted_model(script):
it = iter(script)
return lambda state, schemas: Reply(next(it), 100) # 100 "tokens" per turn: fake value
script = [asks("c1", "read_stock", "SKU-11"),
asks("c2", "read_incoming_orders", "SKU-11"),
{"role": "assistant", "content": "DO NOT REORDER: 4 in stock but 20 incoming."}]
outcome, state = loop(scripted_model(script), "Decide whether to reorder. Read the data with the tools...",
TOOLS, "Should SKU-11 be reordered?")
print(outcome.reason, outcome.turns, outcome.tokens)
for m in state:
print(json.dumps(m, ensure_ascii=False))
outcome, _ = loop(lambda s, t: Reply(asks("cx", "read_stock", "SKU-11"), 100),
"", TOOLS, "loop", max_turns=3)
print(outcome.reason, outcome.turns)
outcome, _ = loop(lambda s, t: Reply(asks("cx", "read_stock", "SKU-11"), 600),
"", TOOLS, "loop", max_turns=50, max_tokens=1000)
print(outcome.reason, outcome.turns, outcome.tokens)
outcome, _ = loop(lambda s, t: Reply(asks("cx", "read_stock", "SKU-11"), 100),
"", TOOLS, "loop", deadline_s=-1)
print(outcome.reason, outcome.turns)
# cumulative cost: at turn n everything is sent again, so you pay the sum 1..n
cost = [sum(range(1, n + 1)) for n in range(1, 11)]
print(cost)
Output obtained (the seven JSON lines of the state are the ones shown above and I omit them here):
# success 3 300
# [... seven JSON lines ...]
# max_turns 3
# budget 3 1200
# deadline 1
# [1, 3, 6, 10, 15, 21, 28, 36, 45, 55]
The four outcome lines are the four exits. success 3 300: three turns at 100 fake tokens each. max_turns 3: the model always asks for the same tool and the ceiling stops it. budget 3 1200: with 600 tokens per turn and a ceiling of 1000, the check fires at the start of the third turn, when the tokens are already 1200, that is, the ceiling was exceeded by one call; it is the behaviour described above. deadline 1: with a negative deadline the loop exits before calling the model. The guardrail, the fourth form of external interruption, is not simulated here.
Note the change of shape: it does not raise exceptions, it returns an Outcome with the reason. The exit reason is the most useful piece of data an agent produces after the answer: aggregated over a thousand runs, it tells you whether the problem is the prompt ("too many max_turns"), the catalogue ("too many budget") or the infrastructure ("too many deadlines").
The exit reason, aggregated: the most useful metric you will have
The reason field in the Outcome object looks like an implementation detail. Aggregated over a volume of runs, it becomes the main diagnostic tool of an agent in operation, because each value points to a different cause and a different intervention. Imagine this table, built on a thousand runs in a week. The numbers are invented for the example, not a measurement: they serve to show how to read it, not to tell you which values to expect.
reason count share
success 871 87.1%
max_turns 62 6.2%
budget 31 3.1%
deadline 24 2.4%
guardrail 12 1.2%
On its own, this table does not say whether the system works: it says where to look, and each row has a precise reading.
A high max_turns means the agent cannot close. There are two possible causes and they are told apart by looking at the sequence of tools in the failed runs: if you see the same tool repeated, the problem is observation (the result does not come back, or comes back in a form the model does not recognise as an answer); if you see different tools following one another without converging, the problem is the completion criterion in the instructions, or the task is really bigger than the ceiling you set.
A high budget with few turns indicates tools that return too much. The bill explodes not because the agent takes many steps, but because each step drags along an enormous context. It is solved in the return values of the tools, not in the prompt.
A high deadline is almost always infrastructure: a slow tool, an unstable network, a model under load. It is not an agent design problem, and it is important to distinguish it, because otherwise you spend days rewriting prompts to cure a database that is missing its indexes.
A high guardrail should be read with suspicion in both directions. If it is high, it may mean the system is under pressure from out-of-scope requests, or that your check has too many false positives and you are blocking legitimate users. The two cases can be told apart only by reading a sample of blocked runs — which is why every guardrail must record why it fired, as we will see later in the module.
The thing to note is that none of these diagnoses requires sophisticated tools. It requires that your loop, instead of raising a generic exception, return an object with four fields, and that you write them in a table. It is half an hour of work that changes the way problems are discussed: from "sometimes it doesn't work" to a sentence such as "a share of the runs does not converge, and in many of those a tool is repeated three times" — with the real figures that you will have measured.
The same agent, three configurations
To close, something that clarifies how independent the five parts are from the model: the same task, three configurations with the same code and the same model, changing only the five parts. I use the OpenAI Agents SDK (package openai-agents, imported as agents), which in the following lessons we will also use on local models. A correction to how this example is often written: tools must be registered with the function_tool decorator, because the SDK does not accept bare functions, and Decision has to exist (here it is a pydantic model with three fields). I installed openai-agents 0.24.0 in a virtual environment outside the repository and ran the block twice: it builds the three agents, it calls no model.
from agents import Agent, ModelSettings, function_tool
from pydantic import BaseModel
@function_tool
def read_stock(sku: str) -> dict:
"""Current stock and reorder threshold of a SKU."""
return {"stock": 4, "threshold": 10}
@function_tool
def read_incoming_orders(sku: str) -> dict:
"""Quantity already ordered and in transit for a SKU."""
return {"incoming": 20}
class Decision(BaseModel):
outcome: str # "reorder" or "do_not_reorder"
quantity: int
reason: str
BASE = dict(name="Warehouse", tools=[read_stock, read_incoming_orders])
# A - permissive: does whatever it likes
a = Agent(**BASE, instructions="Help with questions about the warehouse.")
# B - constrained: completion criterion and tool policy
b = Agent(**BASE, instructions=(
"Decide whether to reorder. ALWAYS read the data with the tools, never from memory. "
"Take into account what is incoming. "
"You are done when you have written one line: REORDER <n> or DO NOT REORDER, "
"plus the reason in one sentence. Add nothing else."))
# C - constrained and typed: no prose, one object
c = Agent(**BASE, instructions=b.instructions, output_type=Decision,
model_settings=ModelSettings(temperature=0.0, tool_choice="required"))
for ag in (a, b, c):
print(ag.name, [t.name for t in ag.tools], ag.output_type, ag.model_settings.tool_choice)
# Warehouse ['read_stock', 'read_incoming_orders'] None None
# Warehouse ['read_stock', 'read_incoming_orders'] None None
# Warehouse ['read_stock', 'read_incoming_orders'] <class '__main__.Decision'> required
The output says one thing only, and should be read for what it is: the three configurations differ in instructions, output type and tool_choice, and in nothing else. I have not measured the runtime behaviour, because it would need a model. What follows is what the theory of the lesson leads you to expect, to be checked with your own data. A is free to answer without calling the tools, because nothing forbids it, and the format of the answer is not constrained. B has a completion criterion and a tool policy, but the output remains prose and downstream you have to interpret it. C cannot avoid calling a tool on the first turn (tool_choice enforces it, if the endpoint supports it: see the note on Ollama) and cannot answer in prose (output_type enforces that), so it is the only one you can connect to other software without writing a parser.
The operational moral holds for the whole module: an agent's behaviour is largely a design choice of yours, not only a property of the model. When something does not work, the first question is "which of the five parts did I leave vague?" even before "do I need a better model?". Often the answer is "the completion criterion" or "the tool-use policy", and it is fixed in a few lines — that is my experience, not a statistic.
Diagnosis: from the symptom to the broken part
The reason it is worth keeping the five parts in mind is this: it turns "the agent doesn't work" into a question with an answer. This list is the path I really follow when something goes wrong.
- "It answers without looking at the data." → Instructions (the tool-use policy is missing) or tool descriptions that are too vague. Quick remedy: force
tool_choiceto "required" on the first turn and check whether the behaviour changes; if it does, the problem was the decision, not the capability. - "It calls the wrong tool." → Catalogue: overlapping names or unclear boundaries. Look at the two candidate tools and ask yourself whether you would know how to choose by reading only name and description.
- "It keeps calling the same tool." → State: most likely you are not appending the assistant message or the result correctly, and the model does not see that it has already called. Second suspect: the tool returns something the model does not recognise as an answer (a
None, an empty string). - "It stops too early." → Termination: the completion criterion in the instructions is vague, so the model declares itself satisfied whenever it likes.
- "It never stops." → Termination, the opposite side: the limit is missing, or two tools pass the ball back and forth. Look at the sequence of calls: if you see A, B, A, B you have a loop.
- "It says it did something it did not do." → None of the five: it is the nature of the model. The remedy is not a better prompt, it is not believing the prose — check against the log of the calls executed.
This list is used in a precise order: first you look at what happened (the sequence of calls), then you formulate the hypothesis, then you change one thing. Changing prompt and catalogue in the same attempt is the most effective way of never understanding what worked.
Design trade-offs
Four design choices pull in opposite directions. Long instructions against context: a detailed system prompt reduces behaviour errors and raises the cost of every turn, so the line that eliminates a whole turn wins and the paragraph that repeats a concept loses. Few rich tools against many simple tools: a few tools with optional parameters confuse the choice less, but each becomes a function with too many responsibilities to validate and test.
High turn limit against low: with a high ceiling more tasks succeed and pathological cases cost a lot; with a low one the cost is predictable and some legitimate task gets cut off, so you decide by looking at the real distribution of turns and not by intuition. Full state against summarised state: the whole history is faithful and expensive, the summary is cheap and loses details exactly when they are needed (an exact quotation, an identifier).
The traps people really fall into
- Forgetting to append the assistant's reply before the tool result. The order is: assistant message with the requests, then the tool messages. Reversing them, or skipping the first, produces validation errors or a model that calls the same tool forever because it does not see that it already called it.
- A single
exceptaround the whole loop. That way you cannot tell "the tool failed" (recoverable: you tell the model) from "the endpoint is down" (not recoverable: you exit). Two separatetryblocks, two behaviours. - Putting volatile data in the instructions. Today's date inside the system prompt is a delayed-action bug. Give it a
today_date()tool. - Ignoring parallel calls. The model can ask for three tools at once. If your loop executes only the first, the other two are left without a result and the conversation breaks or goes into a loop. Always iterate over all the calls.
- Not recording the exit reason. Without that field, all you know is that "sometimes it doesn't work". With that field you have a diagnosis in one query.
The five traps are all of one family: the loop is simple and for that reason seems not to deserve care. The first four produce bugs that show up only in particular conditions (a parallel call, a network error, the day after), the fifth takes away the possibility of noticing them. If you must pick a single defence, pick the fifth: the exit reason recorded in every run costs four fields and makes the other four diagnosable.
What you can do now
Instructions, tools, state, decision, termination: five parts, and every wrong behaviour of an agent can be traced back to one of them. You can now write the loop by hand with its four exits, try it without a model using a script, and locate a symptom in one of the five parts. The loop is trivial, the design is not: the least romantic part (the four exits and the recorded reason) is what separates a toy from something you can put in front of users. In the next lesson, asyncio: the plumbing an agent runs on, we tackle the part that the loop hides: all those "wait for the answer" steps are network I/O, and in Python they are handled with asyncio.
Sources
- Lost in the Middle: How Language Models Use Long Contexts (Liu et al., arXiv:2307.03172) - the result on the worse use of information in the middle of a long context; page reached on 2026-11-12 (HTTP 200), abstract not re-read in full
- OpenAI Agents SDK: Agents - parameters of
Agent(instructions,tools,output_type), consulted on 2026-11-12; the construction of the agents was verified withopenai-agents0.24.0 - OpenAI Agents SDK: ModelSettings - the
tool_choicefield, consulted on 2026-11-12 - Ollama: OpenAI compatibility -
tool_choiceamong the unsupported fields, consulted on 2026-11-12 - Ollama: tool calling - the
tool_namefield of the native API, consulted on 2026-11-12
Executed twice with identical output: the loop script with a scripted model (Python 3.14.4) and the construction of the three agents (temporary virtual environment outside the repository). Not executed: the initial skeleton, which is a pattern, and any run with a real model.
Commenti
Caricamento commenti...
Sign in to comment