An agent works fine across a dozen demo runs. Then a customer asks what your return policy is, the model reads that as process my return, and money moves. Nothing crashed. Every log line looks reasonable. The transcript reads like a competent conversation.
The reflex is to reach for a better prompt, and the reflex is wrong. The prompt was never the control surface. The control surface is the gap between the model deciding something and your code doing it — and in most agent implementations that gap is empty.
This is the part that gets skipped in “build an agent in 30 lines” posts, and it is the only part that matters once real users touch the thing. Below is what goes in that gap: three guards, what each one is actually for, and — the useful bit — the way each fails quietly, so you can look for it in your own code.
I have a specific reason to be confident about the failure modes. I built all three, wrote thirty checks against them, ran a mutation test, and called it done. An independent review then found seven real bugs, and two of them were in the guard I was most sure about. Those are in here too.
Working code:
stages/s01_agent_loop/at tagstage-01. It runs offline with no API key, and every number below comes from that tag.
The loop itself is not the hard part
Here is the whole thing, minus the guards:
while True:
response = client.chat.completions.create(
model=model, messages=messages, tools=schemas, tool_choice="auto"
)
message = response.choices[0].message
if not message.tool_calls: # it answered — we are done
return message.content
messages.append(assistant_message(message))
for call in message.tool_calls:
result = TOOLS[call.function.name](**json.loads(call.function.arguments))
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
That is a complete agent. Fifteen lines, and every framework you have heard of — LangGraph, CrewAI, AutoGen, ADK — is this loop with scaffolding around it.
One line in there is worth staring at:
result = TOOLS[call.function.name](**json.loads(call.function.arguments))
The model did not run that function. It returned a name and a string of JSON, and your code chose to execute it. That distinction is not pedantry — it is the entire reason agents can be made safe at all. Everything below goes on the left side of that line.
Guard 1 — the step limit
The obvious failure: the task is unclear, the tool keeps returning something unhelpful, the model tries again. And again. Tokens burn, nothing finishes.
The obvious fix is a counter. The non-obvious part is what you do when it trips:
result.stopped_by_limit = True
return result # answer stays None
Return nothing. Not a summary of what was tried, not a best guess assembled from partial tool output. An agent that hits its ceiling and then produces a confident paragraph is worse than one that stops, because the paragraph gets read by a human who has no way to know it was manufactured.
How it fails quietly
The counter is trivially correct, so the bug is never in the counter. It is in the definition of step.
If a model returns three tool calls in one response, is that one step or three? Pick the second and your limit means something different depending on how chatty the model feels that day. We define it explicitly: one step is one round trip to the model, however many tools it asked for. That definition sits in the glossary and in a check, because it is exactly the kind of thing two engineers assume differently and never discuss.
Guard 2 — validate before you execute
The model invents town where the schema says city. Your function gets an unexpected keyword and raises, or worse, silently works on the wrong thing.
So arguments get checked against the declared schema before the function is reached. Three details in that sentence are load-bearing, and I got two of them wrong first time.
No type coercion. A string "3" where the schema declares a number is a rejection, not a hint. Coercing it hides a model error at precisely the moment you want to see one.
Booleans are not integers. In Python bool subclasses int, so isinstance(True, int) is True. Without an explicit exclusion, True sails through a check for integer and your user reads “Delivery window: True days”:
if expected in ("integer", "number") and isinstance(value, bool):
return False
Report every problem at once, not the first. This one I only learned from a test. My first validator returned the first mismatch it found. Formally sufficient — the model gets told something is wrong and retries. But look at the actual transcript when it reports everything:
-> model asks get_weather({"town": "Kyiv"})
<- Arguments do not fit — missing required fields: city; unknown fields: town.
-> model asks get_weather({"city": "Kyiv"})
<- Kyiv, +28°C, partly cloudy.
Fixed in one round trip, because it received both facts together. Reporting only the missing field would have taught it about town on the next call — another model round trip, more tokens, more latency, for information you already had.
How it fails quietly
Mine failed open, and I did not notice for a week.
The unknown-field branch only ran when the schema author remembered to set additionalProperties: false. All three of my tools had it, so nothing ever went red. But the stage’s own exercise asks the reader to register a fourth tool, and the shortest schema anyone writes has no such key. Reviewer reproduced it in four lines: extra argument sails through the validator, hits func(**checked), and TypeError escapes the whole run.
A guard that works only when the person defining the schema remembers to switch it on is not a boundary. It is a convention. It now rejects unknown fields unless the schema explicitly permits them — fail closed, one character of difference.
The same review found the sibling bug: validate_arguments assumed its input was a dict. Valid JSON that is not an object — null, 42, "Kyiv", [1,2] — crashed the run instead of being rejected. A model returning arguments: "null" is not a programmer error; it is Tuesday.
Guard 3 — confirmation before anything irreversible
Some tools cannot be undone. Issue a refund, send the email, delete the record. Those get a flag on the tool definition, and the flag means: not without a human saying yes.
Confirmation arrives as a separate run rather than a console prompt:
python -m stages.s01_agent_loop.run # shows what would happen
python -m stages.s01_agent_loop.run --confirm # actually does it
That choice is not cosmetic. input() reads EOF in CI and dies, so an interactive prompt means every test of this guard needs stdin mocking — the first piece of magic in a codebase whose entire purpose is that nothing is magic. As a run argument, the guard is testable in three lines.
How it fails quietly, and this is the one that got me
I checked each tool call as I came to it. Irreversible and unconfirmed? Block. Straightforward.
Now consider a model that requests two irreversible actions in one response — which any model will do the moment a user says “return both of these.”
| per-call check | per-step check | |
|---|---|---|
| unconfirmed run | blocks on the first; the user never learns about the second | lists every irreversible call |
| confirmed run | executes all of them, including the one never shown | executes exactly what it showed |
Reproduced against the real code: unconfirmed, it blocked and displayed ord_4472; the user had no way to know ord_9999 was also queued. Confirmed, it processed both.
So the confirmation was a blanket permit for whatever the model asked next — while my own lesson text promised, in as many words, that “the run shows you exactly what would happen; confirming blind is not confirming.”
The fix is to screen the whole step before executing anything in it, which is now its own module (gate.py). The ordering is the point: look at every call the step contains, then decide, because otherwise some of the actions have already happened by the time you think to ask.
Why I could not see any of this
Not carelessness. The reason is structural and worth naming, because it applies to anyone reviewing their own agent code.
I was checking the implementation against my own model of what it should do. The gate behaved exactly as designed — per call. That the design itself was wrong is invisible from the inside, because the tests were written by the same head as the code and they agreed with each other. They were simply wrong together.
The same thing happened to the lesson text. I wrote an exercise: “remove and not confirmed and the gate stops firing.” It does the opposite — that edit makes the gate block always. A reader would have made the change, seen identical output, and concluded they were looking in the wrong file.
Seven findings, none of them mine.
The test that was right for the wrong reason
The subtlest finding was not in the code at all.
I had a mutation test for the gate: disable it, confirm a check goes red. It did go red — so the test had teeth, or so the reasoning went.
It went red with FakeLLMError: script exhausted. The scripted fake model had exactly one step; the moment the gate stopped short-circuiting the run, the loop asked for a second response and the fake ran out. The assertion that would have said “an irreversible function executed without confirmation” was never reached.
A test with the correct verdict and the wrong cause. It passes a mutation check — that is precisely the criterion mutation testing uses — while proving nothing about the invariant it claims to protect.
Mutation testing proves your test reacts. It does not prove your test reacts to that. Only reading the failure message tells you the second thing.
Both scripts now carry a terminating step, and disabling the gate produces three named AssertionErrors: executed despite the block · irreversible function ran without confirmation · no step_blocked in the demo trace.
The four-line version
| Guard | What it stops | How it fails quietly | Wrong fix |
|---|---|---|---|
| Step limit | Runaway loops burning tokens | Ambiguous definition of “step” when a response carries several tool calls | Raising the limit — the loop is not slow, it is stuck |
| Validation | Invented arguments reaching your function | Fail-open defaults; assuming the payload is an object; reporting only the first problem | A schema library alone — it will not tell you which default it picked |
| Confirmation | Irreversible actions taken on a misread | Screening per call instead of per step, so confirming grants a blanket permit | Trusting the model to ask first |
The right-hand column is why the distinction is worth the words. Every one of those wrong fixes is the one that occurs to you first.
Numbers
From tag stage-01, all measured rather than estimated:
- Loop module: 116 executable lines. Validation: 48. Gate: 39.
- 30 checks, 15 of them on failure modes. They run offline, with no API key, in 1.4 s.
- Demo run: 0.12 s, zero network calls — verified by a reviewer who blocked
socket.connectandgetaddrinfoand confirmed both entry points stayed green. - Review outcome: 7 MAJOR, 10 MINOR. All MAJOR closed; four MINOR deferred with an owner and a date.
The offline number is the one I would defend hardest. It means every failure mode above is reproducible on your machine, for free, deterministically — which is the only reason I can make claims about them rather than describe them.
Try breaking it
Fifteen minutes, and worth more than rereading this:
- In
gate.py, changeand tool.irreversibletoand False. Run the checks: threeAssertionErrors, each naming its own cause. - In
validate.py, delete theboolexclusion. SendTruewhere an integer is declared. Watch it pass. - Set
AGENT_MAX_STEPS=1and see the answer disappear rather than degrade.
git clone https://github.com/AZANIR/agentic-ai && cd agentic-ai
git checkout stage-01
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
python -m stages.s01_agent_loop.run
The tag matters: later stages refactor parts of this — validation moves into a shared layer at stage 3 — so stage-01 is the code this article describes, and main is wherever the course has got to since.
What this does not cover
Three limits, stated so you do not carry them into production by accident.
- The validator handles flat objects with scalar types. Nested objects and arrays are out of scope; use a schema library where it counts.
- The agent has no memory between runs. That is a visible flaw, and a later stage’s subject.
- Green checks measure the logic around the model, not the quality of its answers. Those are different problems with different tooling, and conflating them is its own article.
The guards are not the interesting part of agent engineering. They are the part that decides whether anything else you build gets to run in front of real users.