Session 5. A deterministic mini-agent — Fri 18 Sep
The loop, and the plan that stands in for a model
An agent is a loop you own
Session 4 gave you tools: bounded functions with contracts, validated at the boundary. A tool on its own does nothing. Something has to decide which one to call, execute it, look at the result, and decide again. That something is the loop, and it is yours.
The model contributes one thing per iteration: a decision. Everything that makes the loop safe belongs to the application.
The four beats
| Beat | Who | In our code |
|---|---|---|
| Perceive | you | the question, the retrieved passages, the previous results |
| Decide | the model | answer now, or call this tool with these arguments |
| Act | you | validate at the boundary, then execute |
| Observe | you | append the result, or the typed error, and go round again |
Three of the four are deterministic. That ratio is why an agent can be tested at all.
Plan → act → observe
run_loop walks a list. Each entry is one decision:
plan = [
{"tool": "list_documents", "args": {"tag": "retrieval"}},
{"tool": "convert_currency", "args": {"amount": 100, "source": "USD", "target": "EUR"}},
{"tool": "answer", "args": {"text": "rag-basics covers retrieval, and 100 USD is 92.00 EUR."}},
]
Act: toolsname — opens Fri 18 Sep. Observe: append {"tool", "args", "result"} to
steps. Then the next entry. The answer entry is not a call — it is the
decision to stop, and it carries the text.
Why a scripted plan is enough
Ask a model for the plan and the loop becomes untestable in the exact places that matter. The budget exit only fires when the model happens to want a sixth call. The repetition exit only fires when it happens to repeat itself. You cannot write a test that says "and now the model spins", so those branches ship unexercised.
Write the plan down and every exit becomes reachable on purpose:
| Plan | Exit it reaches |
|---|---|
two calls, then answer |
answered |
| the same call three times | repeated_call |
four calls under budget=2 |
budget |
| a call to a tool that raises | tool_error |
Those four plans are exactly what ch05-e2 runs against your loop. No model, no
network, the same verdict on every machine.
What the scripted plan is not
It is not a simplification you throw away later. Swapping the list for a model
call changes one line — where name and args come from — and nothing else.
Session 8 makes that swap and measures what it costs. Until then, the four
safeguards are the whole lesson, and a model in the middle of them would only
make them harder to see.
There is also a version of this that is not a teaching device at all. When the steps are known in advance, a fixed chain is the correct design: fewer failure modes, no model spend, a trace you can predict. The professional question is never "how autonomous can I make this" but "what is the least autonomy that passes the eval."
The loop you already have
answer_question in src/bootcamp_agent/agent.py is this shape, one level up.
Its trace prints its beats:
[retrieve] top_k=3 -> [('prompt-injection', 1), ('prompt-injection', 0), ...
[llm_call] attempt 1: 121 chars
[decision] answered with citations []
Read it before you write anything. It already has three designed exits: refuse
when retrieval comes back empty, retry once when the model breaks the output
contract, refuse again when the retry breaks it too. Your run_loop adds three
more, on the tool side.