CHAPTER 02 · The Agent Loop · 3 / 5
Code MVP: a working agent loop
This is the skeleton the entire capstone is built around. It runs without a real model by using a tiny scripted stand-in, so you can see the control flow clearly. Replace fake_model with a real API call and it becomes a real agent.
"""
chapter 02: the agent loop.
A minimal, runnable agent loop with a fake model and one real tool.
The structure here is exactly what production harnesses use.
"""
import subprocess
# --- The tools the model is allowed to ask for (Chapter 6 expands this) ---
def tool_shell(command: str) -> str:
"""Run a shell command and return combined output."""
result = subprocess.run(command, shell=True, capture_output=True, text=True)
return (result.stdout + result.stderr).strip()
TOOLS = {"shell": tool_shell}
# --- A fake "model": it returns tool requests, then a final message. ---
# A real model would return these decisions itself. We script them so the
# loop's control flow is visible without an API key.
def fake_model(history: list) -> dict:
# Count how many tool results are already in the history.
tool_results = [h for h in history if h["role"] == "tool"]
if len(tool_results) == 0:
# First call: ask to list files.
return {"type": "tool_call", "name": "shell",
"args": {"command": "echo hello from the agent"}}
else:
# We already ran a tool; now produce a final answer.
last = tool_results[-1]["content"]
return {"type": "text", "content": f"Done. The command said: {last!r}"}
# --- The loop itself. This is the heart of every coding agent. ---
def agent_loop(user_request: str, max_turns: int = 10) -> str:
history = [{"role": "user", "content": user_request}]
for _ in range(max_turns): # a safety cap (Chapter 12)
response = fake_model(history) # 1 & 2: gather + call the model
if response["type"] == "text": # 3a: plain text -> we are done
history.append({"role": "assistant", "content": response["content"]})
return response["content"]
# 3b: a tool call -> execute it, append the result, loop again.
name, args = response["name"], response["args"]
history.append({"role": "assistant", "content": f"(calling {name} {args})"})
output = TOOLS[name](**args)
history.append({"role": "tool", "content": output})
return "Stopped: hit the turn limit without finishing."
if __name__ == "__main__":
print(agent_loop("say hello using the shell"))
Run it and you will see the loop make one tool call, feed the result back, and then finish with a text message. That is the same shape as a fifty-step Codex session; only the number of laps and the realism of the model change.
A few things to notice, because they foreshadow later chapters:
historygrows every lap. That growth is the quadratic problem (Chapter 4).max_turnsis a crude kill switch. Real harnesses make this a proper budget (Chapter 12).TOOLSis one dict with one tool. Real harnesses make it a rich registry with validation and truncation (Chapter 6), and gate every call behind a permission check (Chapter 7).- We blindly trusted the tool call. Production code validates the arguments against a schema first.