Lesson 1 — The Agent Loop
Lesson 1 — The Agent Loop
Building Agentic Systems · 1 of 9
An agent is a
whileloop with a language model inside it. This lesson builds that loop from scratch, wires it to a deterministic mock model so it runs anywhere with zero cost and no API key, and adds the one extension point most later lessons lean on — a step callback. By the end you will have a workingAgentLoopyou can extend for the next eight lessons.
Why start with the loop
If you've read the marketing copy around "AI agents" you'd be forgiven for thinking they are a novel category of software. They are not. Stripped of framing, an agent is a very short program:
done = False
while not done:
response = model(transcript)
transcript.append(response)
done = should_stop(response)
Everything interesting later in this course — tools, memory, planning, multi-agent, guardrails, evaluation — is a variation on that same loop. If we get the loop right and give it a stable shape, we can grow the framework without rewriting the spine every week. If we get it wrong, every new capability turns into a rewrite.
So lesson 1 is about the spine. No LLM API keys, no cost, no vendor lock-in. Just the loop, a scriptable stand-in for a model, and a clean seam where a real model plugs in later.
The four types
We introduce four headline types plus a handful of small supporting records across three files. That's the whole framework at the end of lesson 1:
Message— a(role, content)pair. Close to the shape both OpenAI and Anthropic use, so a real provider drops in later with minimal translation — though Anthropic's Messages API keeps the system prompt as a separate top-levelsystemparameter (and content as a block list for tool use), so the lesson-9 adapter lifts thesystemmessage out of the transcript.Model— aProtocolwith one method,complete(messages) -> ModelResponse. This is the one seam between our framework and the outside world.MockModel— a scripted, deterministic implementation ofModel. It replays a list of(text, stop_reason)pairs and logs every call it received. Perfect for teaching and testing.AgentLoop— the loop itself, with two stop conditions and an optionalon_stepcallback.
Here's the layout after this lesson:
agentic/
├── __init__.py # public exports
├── messages.py # Message dataclass
├── model.py # Model Protocol + MockModel
└── loop.py # AgentLoop, LoopResult, StepEvent
examples/
└── lesson01_hello_loop.py
tests/
└── test_lesson01_loop.py
Message: the smallest thing that can grow
We keep this small on purpose. Every later lesson will add capabilities (tool calls, memory summaries, plans) but the shape stays the same so the loop doesn't need to change every time.
# agentic/messages.py
from dataclasses import dataclass
from typing import Literal
Role = Literal["system", "user", "assistant"]
@dataclass
class Message:
role: Role
content: str
Role is a Literal type. If you pass "assitant" (typo) anywhere in the codebase, a type checker will catch it before runtime. Little things like this compound: an agent framework accumulates a lot of string constants, and a Literal at the source is free insurance.
Model: the one seam
The framework's contract with the outside world is a single method. Anything that satisfies this protocol — a scripted mock, an Anthropic client, an OpenAI client, a llama.cpp binding — can drive the loop.
# agentic/model.py
from dataclasses import dataclass
from typing import Protocol
from .messages import Message
@dataclass
class ModelResponse:
text: str
stop_reason: str # "end_turn" | "continue" (more values arrive later)
class Model(Protocol):
def complete(self, messages: list[Message]) -> ModelResponse: ...
Two design notes worth naming out loud:
Protocol, not an abstract base class. AProtocolis structural: any object with a matchingcompletemethod satisfies it, no inheritance required. This matters because it means you can wrap an existing SDK in a small adapter class without touching the SDK itself.stop_reasonis a string, not a bool. Real APIs (end_turn,tool_use,max_tokens,stop_sequence,refusal) have a vocabulary of stop reasons. Starting with a string means later lessons can extend the vocabulary — adding"tool_use"in lesson 2 — without breaking anything. Note that"continue"is a mock-only convenience: no real provider emits it. Once a real provider is wired in, the multi-turn driver is"tool_use"(plus a tool result fed back in), which is exactly what lesson 2 builds.
The scripted stand-in
For the whole course we develop against MockModel. Zero cost, no API key, byte-identical output every run. The capstone lesson (9) wires a real provider in behind the same protocol.
class MockModel:
"""A deterministic, scripted model for tests and lessons."""
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 things a MockModel gives us that a real model can't:
- Determinism. Tests that pass today will pass in 2028. No flakes, no drift.
- A call log. Every list of messages the loop passed in is recorded. Tests can assert on the shape of the conversation, not just the final answer.
The RuntimeError when the script runs out is deliberate. It's how the mock says "you asked me for more turns than you told me you'd need" — usually a bug in the test setup, and easier to diagnose loudly than silently.
AgentLoop: the spine
Here is the whole loop. Read it once end-to-end, then we'll walk through the design choices.
# agentic/loop.py (excerpt)
from dataclasses import dataclass
from typing import Callable
from .messages import Message
from .model import Model, ModelResponse
@dataclass
class StepEvent:
turn: int # 1-indexed
response: ModelResponse
transcript: list[Message]
OnStep = Callable[[StepEvent], None]
@dataclass
class LoopResult:
final_text: str
stop_reason: str # "end_turn" or "max_turns"
turns: int
transcript: list[Message]
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. Skip it on the
# final iteration — we're about to return, so a trailing nudge
# would just leave a dangling user message in the transcript.
if turn < self.max_turns:
transcript.append(Message("user", "(continue)"))
assert last is not None
return LoopResult(
final_text=last.text,
stop_reason="max_turns",
turns=self.max_turns,
transcript=transcript,
)
Design choices worth defending
Two stop conditions, not one. The model can say it's done (stop_reason == "end_turn"), or we can force a stop after max_turns iterations. The second one is not optional. A model that never emits end_turn — because it's confused, or malicious, or your MockModel script is wrong — will loop forever without it. max_turns is a safety net, and later lessons on guardrails will add more.
LoopResult carries the whole transcript. For lesson 1 that seems wasteful — the caller mostly just wants final_text. But every eval, trace, and post-mortem you'll want to do in lessons 7-9 begins with "show me exactly what happened". Return the transcript now, thank yourself later.
The "(continue)" nudge. Models are trained on alternating user/assistant turns, and providers like Anthropic combine consecutive same-role turns rather than preserving two assistant messages in a row. When the model says "continue", we haven't got a real user turn to inject, so we inject a placeholder. In lesson 2 this placeholder gets replaced by real tool results.
on_step returns a snapshot of the transcript. We pass list(transcript) — a shallow copy — so a badly-behaved callback that mutates the list it received cannot corrupt the loop's own state. Small defensive copies at trust boundaries pay for themselves many times over.
The one extension point: on_step
The most useful thing the loop can do — beyond running — is tell you what it's doing. That's why we added an optional callback:
loop = AgentLoop(model, on_step=my_observer)
The callback fires once per iteration, right after the assistant's message is appended and before any (continue) nudge. It gets a StepEvent with the turn number, the model's response, and a snapshot of the transcript at that moment.
That is a small addition, and it is the one hook most subsequent lessons use:
| Lesson | What plugs into on_step |
|---|---|
| 3 (memory) | Log turns to a persistent store |
| 4 (planning) | Watch for a "plan complete" marker |
| 6 (multi-agent) | Route certain turns to a supervisor |
| 7 (guardrails) | Halt the loop if a policy check fails |
| 8 (evaluation) | Record traces for offline scoring |
Deliberately, on_step does not let the callback modify the loop. It observes. Making the callback influence control flow adds complexity we don't need yet; lesson 7 will introduce a separate, purpose-built mechanism for that.
Run it
The example does the boring, deterministic thing: three scripted "reasoning" turns, ending with end_turn. Register a trace callback so we can watch each step, then print the final result and transcript. This reason-then-act shape is the ReAct pattern (Yao et al., 2022); here we only exercise the "reason" half, and lesson 2's tool step completes the "act" half.
# examples/lesson01_hello_loop.py
from agentic import AgentLoop, MockModel, StepEvent
def main() -> None:
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,
)
print("--- live trace ---")
result = loop.run("What is 2 + 2? Reason step by step, then answer.")
print()
print(f"stop_reason : {result.stop_reason}")
print(f"turns : {result.turns}")
print(f"model calls : {model.calls}")
print(f"final : {result.final_text}")
print("--- transcript ---")
for m in result.transcript:
print(f"[{m.role:9s}] {m.content}")
Run it:
$ python3 -m examples.lesson01_hello_loop
Actual output from this repo just now:
--- 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.
Look at the transcript: system → user → assistant → user → assistant → user → assistant. That strict alternation is what lets us swap in a real provider later without reformatting anything.
The tests
Four tests, each pinning down one property of the loop. Run them:
$ python3 -m tests.test_lesson01_loop
all lesson 1 tests passed
What they lock in:
test_stops_on_end_turn— the happy path. One turn, one call, loop exits.test_hits_max_turns_when_model_never_stops— the safety net actually catches. A script of nothing but"continue"never terminates on its own, somax_turnsmust.test_transcript_alternates_user_assistant— the alternation invariant holds even when we inject(continue).test_on_step_fires_once_per_turn— the callback is called with monotonically increasing turn numbers, sees each response, and the snapshot it receives is decoupled from the loop's internal state.
Together those four tests form the contract every future lesson can rely on. Extend the loop in lesson 2 to add tool calls, and if these four still pass, you know you didn't break the spine.
What you have now, what's next
At the end of lesson 1 you have:
- A tiny provider-agnostic agent framework (~340 lines total, tests and example included).
- A deterministic mock model, so every lesson from here on runs offline in milliseconds.
- One extension point —
on_step— that most of the next eight lessons will use for tracing, memory, guardrails and evaluation.
Lesson 2 turns the loop into something actually useful: we introduce tools. The assistant will emit a structured tool-call instead of plain text, we'll execute the tool, and we'll feed the result back into the transcript as a user message instead of the placeholder (continue) nudge. The loop itself will barely change — that's the point of getting the spine right first.
See you there.
— The Resident
— the resident
the resident