Lesson 2 - The Agent Loop

Welcome to The Agent Loop

In Lesson 1 you watched an agent solve a two-step task on its own — look up Tokyo’s temperature, then convert it — and we said the driver of the program was literally while the model wants a tool: run it and loop. Now you write that loop. It is a surprisingly small piece of code: you call the model with your tools, check whether it asked for one, run the tool if so, hand the result back, and repeat until the model stops asking. By the end you’ll have a single reusable run_agent function you can point at any goal and any toolbox.

This is the most important code in the whole module. Frameworks like LangGraph wrap this exact pattern in conveniences, but the pattern underneath never changes. Understand it once here and the rest of the module is detail.

By the end of this lesson, you will be able to:

  • Define tools and a dispatch function that maps a tool name to the code that runs it
  • Write the core loop logic: check stop_reason, append the assistant turn, run tools, append tool_results
  • Wrap it all into a reusable run_agent(user_message, tools, tool_functions, max_steps=10) function with a safety cap
  • Read a real multi-step trajectory produced by your own loop

You’ll build directly on the two-tool example from Lesson 1. Let’s begin.


Step 1: The Tools and a Dispatcher

We start with the same two tools as Lesson 1: a get_weather lookup and a calculator. Each tool is two separate things, and it helps to keep them straight. The first is the definition — a dictionary in the Anthropic format (name, description, input_schema) that tells the model the tool exists and what arguments it takes. The second is the implementation — the actual Python function your code runs when the model asks for that tool. The model only ever sees the definition; it never runs your code.

import anthropic

client = anthropic.Anthropic()

# --- Tool definitions: what the MODEL sees ---
tools = [
    {
        "name": "get_weather",
        "description": "Get the current temperature in Celsius for a given city.",
        "input_schema": {
            "type": "object",
            "properties": {
                "city": {"type": "string", "description": "The city name, e.g. Tokyo"}
            },
            "required": ["city"],
        },
    },
    {
        "name": "calculator",
        "description": "Evaluate an arithmetic expression and return the numeric result.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "An arithmetic expression, e.g. '(14 * 9/5) + 32'",
                }
            },
            "required": ["expression"],
        },
    },
]

# --- Tool implementations: what YOUR CODE runs ---
def get_weather(city):
    temps = {"Tokyo": 14, "Paris": 9, "Cairo": 27}
    return temps.get(city, "unknown")

def calculator(expression):
    return eval(expression, {"__builtins__": {}}, {})

# --- The dispatcher: map a tool name to its implementation ---
tool_functions = {"get_weather": get_weather, "calculator": calculator}

The tool_functions dictionary is the bridge between the two halves. When the model says “call get_weather”, your loop needs to find the right function to run — and a name-to-function dictionary makes that a single lookup: tool_functions[block.name]. Keeping the definitions in tools and the implementations in tool_functions separate is what lets run_agent stay generic: pass it any pair of lists and it works.

One note on the calculator. We evaluate the expression with eval(expression, {"__builtins__": {}}, {}), which strips away Python’s built-in functions so a tool argument can’t reach anything dangerous. A bare eval on model-generated text would be a security hole; this restricted form keeps it to arithmetic.


Step 2: The Core Loop Logic

Now the engine. Every turn does the same four things: call the model, inspect the stop_reason, and — if the model asked for a tool — run it and feed the result back. Here is the heart of it, before we wrap it in a function:

messages = [{"role": "user", "content": user_message}]

resp = client.messages.create(
    model="claude-haiku-4-5",
    max_tokens=400,
    tools=tools,
    messages=messages,
)

if resp.stop_reason != "tool_use":
    # The model is done — its final answer is in the text blocks.
    final_text = "".join(b.text for b in resp.content if b.type == "text")

else:
    # The model wants one or more tools. Two appends are required.

    # 1. Append the assistant's turn verbatim (resp.content), so the model
    #    can see its own tool_use request on the next call.
    messages.append({"role": "assistant", "content": resp.content})

    # 2. Run each requested tool and collect a tool_result for each one.
    tool_results = []
    for block in resp.content:
        if block.type == "tool_use":
            result = tool_functions[block.name](**block.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,   # must match the tool_use it answers
                "content": str(result),    # tool_result content must be a string
            })

    # 3. Append all tool_results in a single user message, then loop again.
    messages.append({"role": "user", "content": tool_results})

Read the stop_reason check first, because it is the whole decision. If stop_reason is anything other than "tool_use" — almost always "end_turn" — the model is finished and you take its text. If it is "tool_use", you have work to do, and the order is exact:

  • Append the assistant turn. resp.content holds the model’s tool_use block(s). You must put that turn back into messages unchanged, or the next call won’t know what it asked for.
  • Run each tool. A single turn can contain more than one tool_use block, so we loop over them. For each, tool_functions[block.name](**block.input) dispatches to the right function with the model’s arguments.
  • Append the results. Each result becomes a tool_result keyed to its tool_use_id, and all results go back in one user message. The content of a tool_result must be a string — that’s why we wrap the number in str(...).

That’s one iteration. Wrap it in a loop and the model can use the result of step one to decide step two, exactly as you saw in Lesson 1.


Step 3: Wrapping It in run_agent with a Safety Cap

A loop that runs “until the model is done” has an obvious failure mode: what if the model never finishes? A bug in a tool, a confusing result, or an unlucky prompt can leave the model asking for tools forever — and every iteration is another paid API call. So we cap the number of steps. The loop runs at most max_steps times, then gives up gracefully.

def run_agent(user_message, tools, tool_functions, max_steps=10):
    messages = [{"role": "user", "content": user_message}]

    for step in range(1, max_steps + 1):
        resp = client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=400,
            tools=tools,
            messages=messages,
        )

        text = "".join(b.text for b in resp.content if b.type == "text")
        print(f"--- step {step}: stop_reason={resp.stop_reason} ---")
        if text:
            print(f"  text: {text}")

        # Model is done — return its final answer.
        if resp.stop_reason != "tool_use":
            return text

        # Model wants tools — append its turn, run them, append results.
        messages.append({"role": "assistant", "content": resp.content})

        tool_results = []
        for block in resp.content:
            if block.type == "tool_use":
                result = tool_functions[block.name](**block.input)
                print(f"  tool_use: {block.name}({block.input})")
                print(f"  -> result: {result}")
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": str(result),
                })

        messages.append({"role": "user", "content": tool_results})

    return "Stopped: reached max_steps without a final answer."

Notice how the safety cap is just a bounded for loop instead of a while True. If the model returns end_turn (or any non-tool_use reason) on any step, we return immediately — the cap never gets in the way of a healthy agent. It only matters when something has gone wrong. The print statements aren’t part of the machinery; they’re there so you can watch the loop run, which is exactly what we’ll do next.

Why the max_steps cap matters

Without a cap, a misbehaving agent can loop indefinitely — and every loop is a real, billable model call. A tool that keeps returning a confusing result, or a goal the model can’t quite close out, turns into a runaway that quietly burns money (and time) until someone notices. max_steps is a hard ceiling: the loop is bounded by your code, not by the model’s judgment. It’s the single most important guardrail in any agent loop, and it costs you one for statement. Always include it.


Step 4: Running It and Reading the Trajectory

Let’s point run_agent at the same goal from Lesson 1 — one that needs both tools — and read what it produces:

final = run_agent(
    "What is the temperature in Tokyo right now, and what is that in "
    "Fahrenheit? Use your tools.",
    tools,
    tool_functions,
)
print("\nFINAL:", final)

Here is the real output from running this loop against the live API:

--- step 1: stop_reason=tool_use ---
  text: I'll get the current temperature in Tokyo and then convert it to Fahrenheit.
  tool_use: get_weather({'city': 'Tokyo'})
  -> result: 14
--- step 2: stop_reason=tool_use ---
  text: Now let me convert 14°C to Fahrenheit using the formula: (C × 9/5) + 32
  tool_use: calculator({'expression': '(14 * 9/5) + 32'})
  -> result: 57.2
--- step 3: stop_reason=end_turn ---
  text: The current temperature in Tokyo is **14°C**, which is **57.2°F**.

FINAL: The current temperature in Tokyo is **14°C**, which is **57.2°F**.

Trace the control flow through your own code. On step 1, stop_reason is tool_use, so the loop appended the assistant turn, ran get_weather("Tokyo") to get 14, appended that as a tool_result, and went around again. On step 2, the model — now able to see the 14 in the conversation — asked for the calculator with the expression (14 * 9/5) + 32, and your loop ran it to get 57.2. On step 3, stop_reason flipped to end_turn, so the if resp.stop_reason != "tool_use" branch fired and the function returned the final text. Three iterations, two tools, one goal — and max_steps (default 10) was never reached, so it never interfered.

That is the entire agent loop. The same run_agent would handle an agent that makes one tool call or twenty; the only thing that changes is how many times the for loop goes around before end_turn.


Practice Exercises

Exercise 1: Trace the two appends

In step 1 of the trajectory, the loop made two appends to messages before calling the model again. Name both, in order, and explain what would break if you skipped the first one (appending resp.content).

Hint

First it appends the assistant turn ({"role": "assistant", "content": resp.content}), then the user turn with the tool_results. If you skip the assistant append, the next request contains a tool_result with no preceding tool_use for it to answer — the conversation is malformed and the API will reject it. The assistant turn is what the tool_use_id on your result points back to.

Exercise 2: Make the agent hit the cap

Suppose you set max_steps=2 and gave the agent a goal that genuinely needs three steps. Walk through what the loop returns and why. What does this tell you about choosing a max_steps value?

Hint

The for loop runs steps 1 and 2 (both tool_use), never reaches an end_turn, exits the loop, and returns "Stopped: reached max_steps without a final answer.". The cap protected you from an unbounded run but also cut off a legitimate task — so max_steps should be high enough to comfortably exceed your expected step count, while still bounding true runaways.

Exercise 3: Why str() on the result

The loop wraps every tool result with str(result) before putting it in content. The calculator returns the number 57.2 and get_weather returns the integer 14. What happens if you pass the raw number instead of a string, and why is the string requirement easy to forget?

Hint

A tool_result’s content must be a string; passing a raw int or float is an invalid request. It’s easy to forget because your tools naturally return numbers (or dicts, or lists), and the bug only surfaces when the model actually calls the tool — not when you write the code. Converting at the boundary (str(result), or json.dumps(...) for structured data) keeps the contract satisfied.


Summary

You turned a single model call into an agent. The agent loop is a bounded for loop that, each iteration, calls the model with your tools and checks stop_reason: if it’s not tool_use, return the final text; if it is, append the assistant turn (resp.content), run each requested tool via a name-to-function dispatcher, append all the tool_results in one user message, and loop again. A max_steps safety cap guarantees the loop can never run forever, no matter how the model misbehaves. You wrapped this into a reusable run_agent(user_message, tools, tool_functions, max_steps=10) and watched it produce the real three-step Tokyo trajectory — proving that the loop, not any framework, is what makes an agent an agent.

Key Concepts

  • Agent loop — the repeating call-check-act cycle that drives an agent until it’s done.
  • Tool definition vs. implementation — what the model sees (the schema) versus the code your loop runs.
  • Dispatcher — a name-to-function map (tool_functions[block.name]) that routes a requested tool to its code.
  • The two appends — append the assistant turn, then the tool_results, keyed by tool_use_id, in one user message.
  • max_steps cap — a hard ceiling that bounds the loop so a runaway agent can’t loop (or bill) forever.

Why This Matters

This loop is the foundation of every custom agent you will build. Production agents add memory, retrieval, planning, and error handling — but they all sit on top of this same call-check-act-loop skeleton, and they all need a safety cap for exactly the reason you saw here. Frameworks hide the boilerplate, not the idea. Because you wrote run_agent yourself, you can reason about what your agent is doing, debug it when a tool misbehaves, and know precisely where the model’s control ends and your code’s guarantees begin.


Next Steps

Continue to Lesson 3 - Giving Agents Memory and Tools

Extend the loop with conversation memory and a richer toolbox so your agent can handle multi-turn tasks.

Back to Module Overview

Return to the Building AI Agents module overview


Continue Building Your Skills

You’ve built the agent loop from scratch and run it against the live model. Next you’ll give that loop a memory so it can carry context across turns, and expand its toolbox so it can take on richer, more open-ended tasks — turning a single-shot agent into one you can actually hold a conversation with.