Lesson 1 · The Agent Loop
Lesson 1 · The Agent Loop
Nine lessons, one repo, zero API keys. We start with the smallest thing that deserves the name "agent" — a while loop wrapped around a model call — and end the course with a full agent framework built on top of it. Today: get the spine right.
Why start here
Every agent framework you have read about is, at its core, this:
while not done:
response = model(transcript)
transcript.append(response)
done = should_stop(response)
Tools, planning, memory, multi-agent orchestration, guardrails — all of it is decoration on that loop. If the loop is wrong, everything you bolt on inherits the bug. So we build the loop first, prove it works, and don't touch it again for the rest of the course.
Two rules for the whole series:
- Every lesson runs. No hand-wavy pseudocode. You clone the repo, type a command, get output.
- No API keys. We drive the framework against a deterministic
MockModel. The final lesson swaps in a real LLM behind the same interface — same code path, same tests.
The whole framework lives in one package, agentic/. Today it has three files.
The three types
Message — what a turn looks like
# agentic/messages.py
from dataclasses import dataclass
from typing import Literal
Role = Literal["system", "user", "assistant"]
@dataclass
class Message:
role: Role
content: str
That's the whole thing. role matches OpenAI's roles on purpose — when we plug in a real provider in lesson 9, no translation layer is needed there. Anthropic is close but not identical: its Messages API only accepts "user"/"assistant", so the system message gets lifted into a separate system= field — the loop's one provider shim. Later lessons attach tool-call payloads to messages; the shape stays the same so the loop never has to change.
Model — the one seam
# agentic/model.py
class Model(Protocol):
def complete(self, messages: list[Message]) -> ModelResponse: ...
@dataclass
class ModelResponse:
text: str
stop_reason: str # "end_turn" or "continue" for lesson 1
Anything that implements complete is a Model. This is the only boundary between our framework and the outside world. That constraint pays off enormously later — swapping providers is one line, testing is trivial, and the loop has no idea whether it's talking to a mock or a real API.
MockModel — deterministic, scripted, free
class MockModel:
def __init__(self, script: list[tuple[str, str]]) -> None:
self._script = list(script)
self._i = 0
self.call_log: list[list[Message]] = []
def complete(self, messages: list[Message]) -> ModelResponse:
self.call_log.append(list(messages)) # snapshot for tests
text, stop_reason = self._script[self._i]
self._i += 1
return ModelResponse(text=text, stop_reason=stop_reason)
You hand it a list of (text, stop_reason) tuples and it plays them back in order. Every test in this course runs against a MockModel. Every example does too. Runs are byte-identical across machines, which means when a test fails it's because your code broke, not because a hosted model drifted.
The loop itself
# agentic/loop.py (the guts)
class AgentLoop:
def __init__(self, model, max_turns=8, system=None, on_step=None): ...
def run(self, user_prompt: str) -> LoopResult:
transcript: list[Message] = []
if self.system:
transcript.append(Message("system", self.system))
transcript.append(Message("user", user_prompt))
for turn in range(1, self.max_turns + 1):
last = self.model.complete(transcript)
transcript.append(Message("assistant", last.text))
if self.on_step is not None:
self.on_step(StepEvent(turn=turn, response=last,
transcript=list(transcript)))
if last.stop_reason == "end_turn":
return LoopResult(final_text=last.text, stop_reason="end_turn",
turns=turn, transcript=transcript)
# Keep the transcript well-formed for the next call.
transcript.append(Message("user", "(continue)"))
return LoopResult(final_text=last.text, stop_reason="max_turns",
turns=self.max_turns, transcript=transcript)
Five things to notice, because each one is a decision that will matter later:
max_turnsis not optional. Real models sometimes forget to stop. Without a hard cap you get an infinite loop with a very expensive backend. The safety net lives in the loop, so every future capability inherits it for free.stop_reasonis the model's own signal. We do not parse the text or apply heuristics. If the model says"end_turn", we stop; otherwise we keep going."end_turn"is the genuine Anthropic API term;"continue", on the other hand, is a lesson-1 stand-in — real providers signal "keep going" differently (Anthropic, for instance, via a"tool_use"stop reason). It will extend cleanly when we add those tool-use stop reasons in lesson 2.(continue)nudge. Between two assistant turns we insert a user message so the transcript stays alternating. Later lessons replace this with real tool results — the shape of the transcript doesn't change, only what fills that slot.on_stepis our one extension point. A single callback fired once per model call, handed aStepEvent. Tracing, cost accounting, guardrails, and evaluation will all plug in here. We use it in the demo below.- Snapshot the transcript into the event.
list(transcript)is a shallow copy, so an observer can't add or drop entries from the loop's own transcript — that's list-structure isolation. It is not full isolation, though:MessageandModelResponseare non-frozen dataclasses, so the event still holds shared references. An observer that setsevent.response.stop_reasonin place would change the loop's control flow. If that ever bites, freeze the dataclasses (frozen=True) or deep-copy on the way out.
Watching it run — the happy path
# examples/lesson01_hello_loop.py (excerpt)
script = [
("Thinking: 2 + 2 is basic arithmetic.", "continue"),
("Working it out: 2 + 2 = 4.", "continue"),
("Final answer: 4.", "end_turn"),
]
model = MockModel(script)
def trace(ev: StepEvent) -> None:
print(f" step {ev.turn}: stop={ev.response.stop_reason:8s} | {ev.response.text}")
loop = AgentLoop(model, max_turns=5,
system="You are a concise math tutor. Show reasoning, then answer.",
on_step=trace)
result = loop.run("What is 2 + 2? Reason step by step, then answer.")
Run it:
$ python3 -m examples.lesson01_hello_loop
--- live trace ---
step 1: stop=continue | Thinking: 2 + 2 is basic arithmetic.
step 2: stop=continue | Working it out: 2 + 2 = 4.
step 3: stop=end_turn | Final answer: 4.
stop_reason : end_turn
turns : 3
model calls : 3
final : Final answer: 4.
--- transcript ---
[system ] You are a concise math tutor. Show reasoning, then answer.
[user ] What is 2 + 2? Reason step by step, then answer.
[assistant] Thinking: 2 + 2 is basic arithmetic.
[user ] (continue)
[assistant] Working it out: 2 + 2 = 4.
[user ] (continue)
[assistant] Final answer: 4.
Read the transcript from the bottom up. That is what the model "sees" on its last call: the whole history, alternating cleanly. The (continue) nudges are the placeholder that lesson 2 will replace with real tool output.
Watching it stop safely — the runaway path
The other outcome you have to design for is the one where the model never declares itself done. That's what max_turns is for. examples/lesson01_runaway.py scripts a model that says continue ten times in a row and caps the loop at three turns:
script = [(f"still thinking (step {i})...", "continue") for i in range(1, 11)]
loop = AgentLoop(MockModel(script), max_turns=3, on_step=trace)
$ python3 -m examples.lesson01_runaway
--- live trace (max_turns=3, script has 10 entries) ---
step 1: stop=continue | still thinking (step 1)...
step 2: stop=continue | still thinking (step 2)...
step 3: stop=continue | still thinking (step 3)...
stop_reason : max_turns
turns : 3
model calls : 3 # capped, not exhausted
final : still thinking (step 3)...
Three model calls, three turns, stop_reason == "max_turns". The other seven scripted responses were never consumed. The loop stopped itself. If the reason a stop happened matters to your caller — and it usually does — LoopResult.stop_reason is the field to check.
Tests as executable specification
The tests are short by design. They pin the four behaviours the loop has to guarantee for lesson 2 to be able to build on top:
# tests/test_lesson01_loop.py (summary)
test_stops_on_end_turn # model says end_turn → loop returns immediately
test_hits_max_turns_when_model_never_stops # cap fires cleanly
test_transcript_alternates_user_assistant # shape stays well-formed
test_on_step_fires_once_per_turn # observer contract + snapshot isolation
$ python3 -m tests.test_lesson01_loop
all lesson 1 tests passed
Every future lesson will add tests against this same MockModel. When we introduce tools in lesson 2, the loop's existing behaviours cannot regress or none of the lesson-1 examples will run — and that is exactly the point. The tests are the contract.
What we haven't done yet (on purpose)
- Tools. The assistant can talk, not act. Lesson 2.
- Streaming.
complete()is atomic. Streaming is a rendering concern, not a loop concern; it will slot in at theModelseam without touching the loop. - Async. The loop is synchronous. It becomes async when it needs to — probably lesson 6, when we run multiple agents in parallel.
- A real provider.
MockModelis doing all the work. Lesson 9 wires a real one in behind the sameModelprotocol and every test still passes.
Every one of those is a change behind the seam or around the loop — never inside it. That's the payoff for keeping today's core small.
Recap and homework
You now have:
- A
Messagetype shared across the framework. - A one-method
Modelprotocol that is our only outside-world boundary. - A deterministic
MockModelso every run is free and byte-identical. - An
AgentLoopwith two stopping conditions (end_turn,max_turns) and one extension point (on_step).
Try before lesson 2:
- Change
max_turnsin the hello example to2. Rerun. Predictstop_reasonfirst, then check. - Write a MockModel script where the first response says
end_turn. How many entries in the transcript? Confirm. - Wrap
traceand a message-counter into oneon_stepcallback that counts how many messages exist at each turn. Notice the+2per step and think about why.
Next lesson: Structured Tool Calls. We give the assistant hands. The loop won't change — only what stop_reason can mean.
— The Resident
— the resident
the resident