Lesson 1 — The Agent Loop
Lesson 1 — The Agent Loop
Building Agentic Systems · 1 of 9
An "agent" sounds mystical until you write the loop. Then it turns out to be one while loop, three data classes, and a strict contract with the model. This lesson builds exactly that — no LLM required, no API key, deterministic output — and finishes with a runnable script that drives a mock model through one tool call and a final answer. Every later lesson (tools, memory, planning, sub-agents, guardrails, evaluation) hangs off this skeleton.
What we're actually building
An agent in this course means: a program that takes a goal in plain English, hands it to a language model, and — critically — lets the model call functions we defined to make progress. The model doesn't run the functions; we do. The model just asks. When it stops asking and starts answering, we stop.
That whole thing lives in one function called run_agent. It's about twenty lines of real logic. Everything else in this repo — the memory in lesson 3, the planner in lesson 4, the sub-agent coordinator in lesson 5, the guardrails in lesson 6 — plugs into this loop without changing its shape.
We're using a MockLLM for now: a deterministic stand-in that replays a hand-written script of assistant messages. No network, no cost, identical output every time. Lesson 7 swaps in a real provider behind the same interface. If a technique only works when you can afford to be wrong a hundred times over a real API, it's not a technique worth teaching first.
The core types
Before code, vocabulary. The framework name is agentkit, and the core types live in agentkit/types.py:
Role— one of"system","user","assistant","tool". These are the four the loop works in — OpenAI-style; the Anthropic adapter (lesson 7) liftssystemto Anthropic's top-levelsystemrequest field and mapstoolresults onto user-role content blocks.Message— one entry in the running transcript. Has a role, optionalcontent, optionaltool_calls, and (for tool results) an optionaltool_call_idpointing back at the call it answers.ToolCall— the model's request to invoke one function: an id, a tool name, and an arguments dict. The id is what lets the model correlate requests with results when it makes several calls in one turn.Tool(inagentkit/tools.py) — a Python callable plus a JSON-Schema description of its parameters that the model sees. AToolRegistryholds a bunch of them and knows how to dispatch by name.
One more type you'll see in the signatures below: RunResult (also in agentkit/types.py) — what run_agent returns: the final answer, the full messages transcript, and the turns it took.
Two things worth noticing already:
- These are plain dataclasses. Zero runtime dependencies. The loop should read like the model in your head, not like a framework.
- The
Toolschema is agentkit's own internal shape (name,description,parameters). Provider adapters serialize it into vendor JSON later (OpenAI keepsparameters, Anthropic renames it toinput_schema); the loop itself never touches vendor formats.
The provider-agnostic contract
Every model in this course — mock, OpenAI, Anthropic, whatever — implements one Python protocol, in agentkit/llm.py:
@runtime_checkable
class LLM(Protocol):
def complete(
self,
messages: list[Message],
tools: list[dict[str, Any]],
) -> Message: ...
One method. You give it the running transcript and the tool specs; it gives you back one assistant Message. That message either:
- has plain
contentand notool_calls— the agent stops and returns that content, or - has
tool_calls— the loop runs them, appends the results to the transcript, and asks again.
That "either/or" is the whole control flow. Internalize it now; the loop is trivial once you see the contract.
The loop itself
Here's the actual body of run_agent, from agentkit/loop.py (lightly trimmed to the lesson-1 essentials — the memory parameter is still there in the real file, we'll ignore it until lesson 3, and the on_event tracing hooks that fire on each step have been stripped out here too, which is why you won't see them called below even though the examples pass on_event=trace):
def run_agent(
goal: str,
llm: LLM,
tools: ToolRegistry,
system: str | None = None,
max_turns: int = 10,
on_event=None,
) -> RunResult:
messages: list[Message] = []
if system:
messages.append(Message(role="system", content=system))
messages.append(Message(role="user", content=goal))
specs = tools.specs()
for turn in range(1, max_turns + 1):
reply = llm.complete(messages, specs)
messages.append(reply)
if not reply.tool_calls:
return RunResult(answer=reply.content, messages=messages, turns=turn)
for call in reply.tool_calls:
result = tools.invoke(call.name, call.arguments)
messages.append(
Message(role="tool", tool_call_id=call.id, content=result)
)
raise RuntimeError(f"agent exceeded max_turns={max_turns} without final answer")
Read it out loud once:
- Build a transcript with the (optional) system prompt and the user's goal.
- Ask the model. Append its reply.
- No tool calls? Done. Return the content.
- Tool calls? Run each one, append its result as a
tool-role message with the matchingtool_call_id, and go back to step 2. - Safety belt: if we do this
max_turnstimes without the model producing a final answer, blow up loudly instead of spinning forever.
That's it. That's the "agent." It's a plain tool-calling loop — ask, run any requested tools, feed the results back, repeat. It resembles the ReAct pattern (Yao et al., 2022 — reason, act, observe, repeat), but ReAct interleaves explicit chain-of-thought reasoning traces between the actions; this minimal loop branches only on tool_calls vs. content and requires no such traces.
Two things people miss on first read:
- The model never runs code. It requests calls; step 4 (which is our code) executes them. This is the trust boundary that lesson 6 will harden.
- Tool results go back in as messages. The model sees a
tool-role message containing the string your Python function returned —ToolRegistry.invokecoerces the return value tostr, so int-returningaddlands as'42'. That's how "the model reads the answer and decides what to do next" actually works.
The mock model
To exercise this loop without an API key, agentkit/providers/mock.py gives us a MockLLM:
class MockLLM:
def __init__(self, script):
self._script = script
self._i = 0
def complete(self, messages, tools):
if callable(self._script):
return self._script(messages, tools)
reply = self._script[self._i]
self._i += 1
return reply
Two flavors: a list of replies played in order (fine when the transcript is scripted), or a callable that takes (messages, tools) and returns a Message (needed when the reply should depend on what happened). We'll use both — the list form for the happy path, the callable form for a runaway model in a moment.
MockLLM implements the same LLM protocol every real provider will. The loop cannot tell them apart. That's the point.
Part 1: the "hello, agent" run
examples/lesson1_hello.py drives the loop through the smallest interesting run — one tool call, one final answer.
def add(a: int, b: int) -> int:
return a + b
tools = ToolRegistry([
Tool(
name="add",
description="Add two integers and return the sum.",
parameters={
"type": "object",
"properties": {
"a": {"type": "integer"},
"b": {"type": "integer"},
},
"required": ["a", "b"],
},
fn=add,
),
])
llm = MockLLM(script=[
Message(
role="assistant",
tool_calls=[ToolCall(id="call_1", name="add", arguments={"a": 12, "b": 30})],
),
Message(role="assistant", content="12 + 30 = 42."),
])
result = run_agent(
goal="What is 12 + 30?",
llm=llm,
tools=tools,
system="You are a careful calculator. Use the tools provided.",
on_event=trace, # a tiny tracer defined in the file
)
Two scripted turns. First turn: call add(12, 30). Second turn: after the model sees the tool result, produce prose. Running it:
$ python3 examples/lesson1_hello.py
[user] What is 12 + 30?
[assistant] -> tool_call add({'a': 12, 'b': 30}) id=call_1
[tool] add -> '42'
[assistant] 12 + 30 = 42.
final answer: '12 + 30 = 42.'
turns: 2
messages: 5 in transcript
Trace that against the loop:
| Loop step | What happened |
|---|---|
| Seed transcript with system + user | 2 messages so far |
Turn 1: llm.complete() returns tool-call message |
append → 3 messages |
Execute add(12, 30) → 42, append as tool message |
4 messages |
Turn 2: llm.complete() returns plain content |
append → 5 messages, return |
Two turns, five messages, one final answer. The loop did nothing clever — it just followed the contract.
Part 2: the safety belt
The loop keeps going until the model stops asking. What if it doesn't? Adversarial input, a broken prompt, a fine-tuned model with a stuck groove — anything that keeps the model in tool-call mode is an infinite loop unless something stops it.
That's what max_turns is for. examples/lesson1_runaway.py proves it works, using the callable form of MockLLM to simulate a model that will never stop (the example registers a trivial noop tool that ignores its argument and returns 1 — that one-line registration is elided here):
def bad_model(messages, tools_specs):
n = sum(1 for m in messages if m.role == "assistant")
return Message(
role="assistant",
tool_calls=[ToolCall(id=f"call_{n+1}", name="noop", arguments={"x": 1})],
)
llm = MockLLM(script=bad_model)
try:
run_agent(goal="please loop forever", llm=llm, tools=tools, max_turns=3, on_event=trace)
except RuntimeError as e:
print(f"loop stopped by safety belt: {e}")
Running it:
$ python3 examples/lesson1_runaway.py
[assistant] -> noop({'x': 1})
[tool] -> '1'
[assistant] -> noop({'x': 1})
[tool] -> '1'
[assistant] -> noop({'x': 1})
[tool] -> '1'
loop stopped by safety belt: agent exceeded max_turns=3 without final answer
Three turns of noop(1) → 1, then a controlled RuntimeError instead of a hung terminal. That failure is the feature. max_turns is a crude budget — lesson 3 replaces it with a token-aware one, and lesson 6 layers per-tool guardrails on top — but even the crude version means the loop cannot hang the process.
The general principle you'll see over and over in this course: loops around models need bounds. Time, turns, tokens, dollars — pick any, but pick something. A loop with no bound is a bug pretending to be a feature.
What we didn't do (and where it goes)
Deliberately omitted from lesson 1, each queued for a later lesson:
- Type-hinted tools with generated schemas. We hand-wrote a JSON Schema for
add. Lesson 2 introduces the@tooldecorator that readsinspect.signature+get_type_hintsand builds the schema for you, plus argument validation that returns errors to the model as tool results. - Memory + compaction. The transcript grows without bound. Lesson 3 wraps it in a
Memorywith a token budget and acompact(llm)step that summarizes the boring middle through the sameLLMprotocol. - Planning. Lesson 4 adds an explicit
Plan → Task[]scaffold so multi-step work is legible instead of implicit. - Sub-agents. Lesson 5 shows one agent dispatching work to specialized sub-agents through the same loop.
- Guardrails. Lesson 6 adds a
Policylayer between argument validation and execution so denied calls come back to the model as a normal tool-error string, not a crash. - Real providers. Lesson 7 slots OpenAI/Anthropic adapters behind the same
LLMprotocol, and every prior lesson keeps running unchanged.
All of it plugs into the loop you just wrote. None of it changes the loop.
Run it yourself
git clone https://github.com/ehabhussein/building-agentic-systems
cd building-agentic-systems
python3 examples/lesson1_hello.py # golden path
python3 examples/lesson1_runaway.py # safety belt
No install, no API key, no network. If it prints 42 and stops, the loop works. Now you own the primitive the next eight lessons will build on.
— The Resident
— the resident
the resident