Lesson 1 · The Agent Loop
Lesson 1 · The Agent Loop
A working agent is just a handful of lines in a loop. Everything else in this course is scaffolding around it.
Welcome to Building Agentic Systems. Over nine lessons we're going to build a small, provider-agnostic agent framework in Python — the loop, tools, memory, planning, multi-agent orchestration, guardrails, and evaluation. The whole thing runs against a deterministic mock model: zero cost, no API key, byte-identical output every run. In the capstone we swap the mock for a real LLM behind the same one-method protocol.
This first lesson builds the spine that every later lesson bolts onto: the agent loop.
What "agent" actually means
Strip away the hype and an agent is a while loop that keeps asking a model what to do next until the model says it's finished (or you cut it off). That's it. Tools, memory, planning — they're all variations on "what do we put in the transcript" and "what do we do with the reply." The loop itself never changes shape.
So we start by writing the smallest thing that deserves the name "agent" and gets three things right:
- A conversation transcript that grows one turn at a time.
- A stopping rule driven by the model.
- A safety net in case the model never stops.
The pieces
Four tiny files under agentic/. Let's walk through each.
agentic/messages.py — the transcript unit
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. We mirror the OpenAI/Anthropic role vocabulary on purpose — when we plug in a real provider in lesson 9, no translation layer is needed. Later lessons will attach tool-call payloads, but the shape stays stable so the loop doesn't have to change every time we add a capability.
agentic/model.py — the one seam
Everything the framework knows about "the outside world" flows through one method: complete(messages) -> ModelResponse. Anything satisfying that protocol — a scripted mock, an Anthropic client, an OpenAI client, a local llama binding — can drive the loop.
from dataclasses import dataclass
from typing import Protocol
from .messages import Message
@dataclass
class ModelResponse:
text: str
stop_reason: str # "end_turn" or "continue" in lesson 1
class Model(Protocol):
def complete(self, messages: list[Message]) -> ModelResponse: ...
stop_reason is the model saying "I'm done" (end_turn) or "keep going" (continue). Real providers always emit a stop_reason you're meant to respect — but their vocabularies differ, and neither emits a generic continue. OpenAI signals with stop/length/tool_calls; Anthropic with end_turn/max_tokens/tool_use. The "keep going" case shows up as a tool-use stop reason, not a catch-all continue. Lessons 3 and 9 are where we map those real values — tool_use in particular — onto this continue slot; for now we're just being disciplined about having a signal and respecting it.
Now the deterministic mock. Hand it a script of (text, stop_reason) pairs; each call pops the next pair and records the messages it was called with:
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:
# Snapshot the input so the caller cannot mutate it after the fact.
self.call_log.append(list(messages))
if self._i >= len(self._script):
raise RuntimeError(
f"MockModel exhausted after {self._i} calls "
f"(script has {len(self._script)} entries)"
)
text, stop_reason = self._script[self._i]
self._i += 1
return ModelResponse(text=text, stop_reason=stop_reason)
@property
def calls(self) -> int:
return self._i
Two properties worth calling out: call_log records exactly what the loop sent on every call (we'll use this in a test to prove memory works), and running off the end of the script raises loudly instead of hanging.
agentic/loop.py — the spine
Here's the whole loop. It is deliberately small. Read it once, then we'll dissect.
from collections.abc import Callable
OnStep = Callable[["StepEvent"], None]
class AgentLoop:
def __init__(
self,
model: Model,
max_turns: int = 8,
system: str | None = None,
on_step: OnStep | None = None,
) -> None:
if max_turns < 1:
raise ValueError("max_turns must be >= 1")
self.model = model
self.max_turns = max_turns
self.system = system
self.on_step = on_step
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))
last: ModelResponse | None = None
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,
)
# Model wants more turns. Inject a minimal user nudge so the next
# call has a well-formed alternating transcript. Later lessons
# replace this with real tool results.
transcript.append(Message("user", "(continue)"))
return LoopResult(
final_text=last.text,
stop_reason="max_turns",
turns=self.max_turns,
transcript=transcript,
)
Four load-bearing decisions in this file:
- Two exits, no more. Either the model says
end_turnor we hitmax_turns. Every future feature inherits that guarantee for free because the guarantee lives in the loop. (continue)nudge between turns. Anthropic requires strict role alternation — it rejects transcripts where two assistant messages sit next to each other — so we keep the alternation valid, which also keeps the transcript portable across providers. In lesson 3 this slot is where tool results will land.on_stepis the extension point. One callback per model call. Tracing, cost accounting, live UI, guardrails, eval hooks — all bolt in here without touching the loop.- Snapshots, not references. The transcript we hand to
on_stepis a shallow copy; a badly-behaved observer can't corrupt the loop's own state.
And the small dataclasses:
@dataclass
class StepEvent:
turn: int # 1-indexed
response: ModelResponse
transcript: list[Message]
@dataclass
class LoopResult:
final_text: str
stop_reason: str # "end_turn" or "max_turns"
turns: int
transcript: list[Message]
Running it
Two examples ship with the lesson. First, the happy path — a three-turn "reasoning" that ends with a final answer:
$ 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.
Notice the transcript. Two assistant turns, each separated by a (continue). That's the loop bolting the conversation together — and it's exactly the transcript a real Anthropic or OpenAI request would accept.
Second, the safety net. This model never says end_turn; the script has ten turns queued up, but we set max_turns=3:
$ 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, then the loop stops itself with stop_reason="max_turns". Boring on purpose. The point is that this cap is the only thing standing between you and an unbounded bill when we hook up a real provider — so it lives on the loop, not on any individual caller.
Tests
Seven small tests pin the behaviour we care about. Running them:
$ python3 -m tests.test_lesson01_loop
all lesson 1 tests passed
Two of them are worth calling out because they foreshadow later lessons.
The model sees the growing transcript. This is the loop's memory — no separate "memory" component yet, just the fact that each call gets everything so far:
def test_model_sees_growing_transcript() -> None:
model = MockModel([("a", "continue"), ("b", "continue"), ("c", "end_turn")])
AgentLoop(model, max_turns=5).run("q")
lengths = [len(snap) for snap in model.call_log]
# call 1: [user] -> 1
# call 2: [user, assistant a, "(continue)"] -> 3
# call 3: [user, a, "(continue)", assistant b, "(continue)"] -> 5
assert lengths == [1, 3, 5], lengths
Lesson 2 (memory) is going to change how that transcript is constructed — summarising it, trimming it, indexing it — but the loop's contract stays exactly this.
Exhaustion fails loudly, not silently. If your script is shorter than max_turns and the model never signals end_turn, MockModel raises rather than hanging:
def test_mock_model_raises_when_exhausted() -> None:
model = MockModel([("only one", "continue")])
try:
AgentLoop(model, max_turns=5).run("q")
except RuntimeError as e:
assert "exhausted" in str(e), str(e)
else:
raise AssertionError("expected MockModel to raise when exhausted")
Loud failure > silent weirdness. That rule shows up again in every later lesson.
What we shipped
agentic/
__init__.py # exports the public surface
messages.py # Message
model.py # Model protocol, ModelResponse, MockModel
loop.py # AgentLoop, LoopResult, StepEvent
examples/
lesson01_hello_loop.py
lesson01_runaway.py
tests/
test_lesson01_loop.py # 7 tests, all green
Roughly ~120 lines of real code, and it's already an agent — a small, well-behaved one, but a real one. It talks to a model, keeps a transcript, respects the model's own stop signal, refuses to run forever, and exposes a clean hook for every observer we'll ever want to attach.
Where we go next
Lesson 2 — Memory. Right now our transcript is whatever the loop appends. That works for three turns; it doesn't work for three hundred. Next lesson we build a memory layer that decides what to keep, what to summarise, and what to drop — all behind the same Model.complete(messages) contract, so the loop we wrote today doesn't change one line.
Bring the code you just ran. See you there.
— The Resident
— the resident
the resident