Frontend Labs
⋯

Chapter 51 — The agent loop, spelled out

In one sentence

An agent is a while loop: ask the model what to do, do it, tell the model what happened, repeat — until it says it's finished or you stop it.

The loop

   +-----------------------------------------------------------+
   |                                                           |
   |   1. THINK   send the conversation + tool list to the LLM |
   |                                                           |
   |   2. ACT     if it returns a TOOL CALL:                   |
   |                  YOUR CODE executes the tool              |
   |                                                           |
   |   3. OBSERVE append the tool's result to the conversation |
   |                                                           |
   |   4. REPEAT  ------------------------------------->-------+
   |
   |   ...until the model returns a normal answer instead of a
   |      tool call, OR you hit a limit (steps, time, cost).
   +-----------------------------------------------------------+

That's the whole idea. It's often called the ReAct pattern (Reasoning + Acting), because the model alternates between thinking and doing.

What it looks like as a conversation

   USER:       "What's the weather in Paris, and should I take a coat?"

   MODEL:      [tool_call: get_weather(city="Paris")]        <-- step 1: ACT

   YOUR CODE:  runs get_weather("Paris")  ->  {"temp_c": 8, "rain": true}

   YOU APPEND: [tool_result: {"temp_c": 8, "rain": true}]    <-- step 2: OBSERVE

   MODEL:      "It's 8 C and raining in Paris -- yes, take a coat
                and something waterproof."                   <-- done, no tool call

Notice: the model never runs anything. It emits a request to run something. Your code decides whether to honour it. That gap is where every guardrail in Chapter 60 lives.

The loop in ~40 lines

This is a real, working agent. Read it slowly; it's the heart of the Part.

import json

def run_agent(user_message, tools, tool_impls, client, model,
              max_steps=10, max_cost_usd=0.50):
    """
    tools:      list of tool SCHEMAS (what the model sees)
    tool_impls: dict of name -> python function (what actually runs)
    """
    messages = [
        {"role": "system", "content":
            "You are a helpful assistant. Use tools when needed. "
            "When you have the answer, reply normally without calling a tool."},
        {"role": "user", "content": user_message},
    ]

    spent = 0.0
    for step in range(max_steps):                       # <-- HARD LIMIT
        response = client.chat(model=model, messages=messages, tools=tools)
        spent += estimate_cost(response)                 # <-- BUDGET LIMIT
        if spent > max_cost_usd:
            return "Stopped: cost limit reached."

        messages.append(response.message)

        # No tool call? The model is done.
        if not response.tool_calls:
            return response.message["content"]

        # Otherwise: execute each requested tool
        for call in response.tool_calls:
            name = call.function.name
            args = json.loads(call.function.arguments)

            print(f"  step {step}: calling {name}({args})")   # <-- OBSERVABILITY

            if name not in tool_impls:                        # <-- ALLOWLIST
                result = {"error": f"unknown tool {name}"}
            else:
                try:
                    result = tool_impls[name](**args)
                except Exception as e:
                    result = {"error": str(e)}                # <-- errors go BACK
                                                              #     to the model
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,
                "content": json.dumps(result)[:4000],         # <-- TRUNCATE
            })

    return "Stopped: step limit reached."

The five things in that code that aren't the loop

They look like details. They are the difference between a demo and something you can run in production.

Line Why it's there
max_steps An agent will loop forever given the chance. Always cap it.
max_cost_usd A loop that costs $0.01 a step can cost $500 overnight. Always cap it.
if name not in tool_impls Never dispatch on a model-supplied string without an allowlist.
except Exception -> result Errors go back to the model as an observation. It can then correct itself — that's a large part of why agents work at all.
[:4000] truncation A tool returning a 2 MB file will blow your context window (Chapter 45) and your budget.

Why does this work at all?

Because the model was trained on enormous amounts of text where people reason step by step, and post-trained (Chapter 34) on examples of using tools correctly. Each loop iteration is just next-token prediction (Chapter 31) — but the context now includes what actually happened last step, which grounds the next decision in reality rather than guesswork.

Practice (45 min) — build one yourself

Start from the loop above. Use any provider's API, or a local model that supports tool calling.

Step 1 — define two tools:

import ast, datetime, math, operator

OPS = {
    ast.Add: operator.add, ast.Sub: operator.sub,
    ast.Mult: operator.mul, ast.Div: operator.truediv,
    ast.Pow: operator.pow, ast.USub: operator.neg,
}

def safe_calculate(expression):
    def eval_node(node):
        if isinstance(node, ast.Expression):
            return eval_node(node.body)
        if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
            return node.value
        if isinstance(node, ast.BinOp) and type(node.op) in OPS:
            return OPS[type(node.op)](eval_node(node.left), eval_node(node.right))
        if isinstance(node, ast.UnaryOp) and type(node.op) in OPS:
            return OPS[type(node.op)](eval_node(node.operand))
        if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and
                node.func.id == "sqrt" and len(node.args) == 1):
            return math.sqrt(eval_node(node.args[0]))
        raise ValueError("Only numbers, +, -, *, /, **, parentheses, and sqrt() are allowed.")
    return eval_node(ast.parse(expression, mode="eval"))

tool_impls = {
    "get_time": lambda timezone="UTC":
        {"now": datetime.datetime.now(datetime.timezone.utc).isoformat()},
    "calculate": lambda expression:
        {"result": safe_calculate(expression)},
}

tools = [
  {"type": "function", "function": {
     "name": "get_time",
     "description": "Get the current UTC time.",
     "parameters": {"type": "object", "properties": {}}}},
  {"type": "function", "function": {
     "name": "calculate",
     "description": "Evaluate a simple arithmetic expression.",
     "parameters": {"type": "object",
                    "properties": {"expression": {"type": "string"}},
                    "required": ["expression"]}}},
]

Step 2 — run it and watch the loop:

print(run_agent("What time is it, and what is 17 * 23 + sqrt(144)?",
                tools, tool_impls, client, model))

You should see two tool calls printed, then a final answer.

Step 3 — experiments (this is where the learning is):

  1. Ask something needing no tools ("Who wrote Hamlet?"). The loop exits after one iteration. Good.
  2. Make calculate always throw an exception. Watch the model receive the error and try something else. This is the single most illuminating experiment in the chapter.
  3. Set max_steps=1 and ask a two-step question. Watch it fail gracefully.
  4. Remove the system prompt line about replying normally when done. Does it still stop cleanly?
  5. Add a print of the full messages list at the end. Read it. That growing list is the agent's entire mind — there's nothing else.

Common confusions

  • "Where does the agent's 'thinking' live?" In the messages list, and nowhere else. Every step re-sends the whole conversation. There is no hidden state.
  • "Does the model run my code?" No. It emits a structured request. Your code chooses to run it. Keep that boundary sacred (Chapter 61).
  • "Why does it sometimes call the same tool twice?" Usually because the first result didn't answer the question, or your tool description is ambiguous. Tool descriptions are prompts — write them carefully.
  • "How does it know when to stop?" It stops when it produces a normal message instead of a tool call. Your max_steps is the backstop for when it doesn't.

Check yourself

  1. Describe the four stages of the agent loop.
  2. Who actually executes a tool — the model or your code?
  3. Why do you send tool errors back to the model?
  4. Name three limits every agent loop must have.
  5. Where is the agent's state stored?

Further reading

  • Paper (readable): "ReAct: Synergizing Reasoning and Acting in Language Models" (Yao et al., arXiv:2210.03629) — the origin of this loop.
  • Article: "Building Effective Agents" — Anthropic. The "autonomous agent" section describes exactly this loop.
  • Code: read the source of a small agent library (e.g. smolagents from Hugging Face) — small enough to read in an afternoon, and it demystifies frameworks.

Generated from real-life-ai-example-v1.md · 8 Oct 2026 16:36