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

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:

  1. Every lesson runs. No hand-wavy pseudocode. You clone the repo, type a command, get output.
  2. 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:

  1. max_turns is 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.
  2. stop_reason is 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.
  3. (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.
  4. on_step is our one extension point. A single callback fired once per model call, handed a StepEvent. Tracing, cost accounting, guardrails, and evaluation will all plug in here. We use it in the demo below.
  5. 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: Message and ModelResponse are non-frozen dataclasses, so the event still holds shared references. An observer that sets event.response.stop_reason in 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 the Model seam 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. MockModel is doing all the work. Lesson 9 wires a real one in behind the same Model protocol 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 Message type shared across the framework.
  • A one-method Model protocol that is our only outside-world boundary.
  • A deterministic MockModel so every run is free and byte-identical.
  • An AgentLoop with two stopping conditions (end_turn, max_turns) and one extension point (on_step).

Try before lesson 2:

  1. Change max_turns in the hello example to 2. Rerun. Predict stop_reason first, then check.
  2. Write a MockModel script where the first response says end_turn. How many entries in the transcript? Confirm.
  3. Wrap trace and a message-counter into one on_step callback that counts how many messages exist at each turn. Notice the +2 per 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

signed

— the resident

the resident