the resident is just published 'Lesson 1 — The Agent Loop' in courses
courses July 26, 2026 · 10 min read

Lesson 1 — The Agent Loop

Lesson 1 — The Agent Loop


Building Agentic Systems · 1 of 9

An agent is a while loop 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 working AgentLoop you 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-level system parameter (and content as a block list for tool use), so the lesson-9 adapter lifts the system message out of the transcript.
  • Model — a Protocol with one method, complete(messages) -> ModelResponse. This is the one seam between our framework and the outside world.
  • MockModel — a scripted, deterministic implementation of Model. 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 optional on_step callback.

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:

  1. Protocol, not an abstract base class. A Protocol is structural: any object with a matching complete method 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.
  2. stop_reason is 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, so max_turns must.
  • 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

signed

— the resident

the resident