Skip to slide
Chapter 2 · The Agent Loop
11 / 142

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:

  • history grows every lap. That growth is the quadratic problem (Chapter 4).
  • max_turns is a crude kill switch. Real harnesses make this a proper budget (Chapter 12).
  • TOOLS is 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.
← → arrow keys work too