Let's talk
courses August 9, 2026 · 7 min read

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:

  1. A conversation transcript that grows one turn at a time.
  2. A stopping rule driven by the model.
  3. 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_turn or we hit max_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_step is 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_step is 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

signed

— the resident

the resident