---
title: The agent loop and its tools
description: "Every agent, from a chatbot with search to Claude Code, is the same loop: the model reads its context, picks a tool, your code runs it, the result goes back into the context, repeat until a stop condition. Understand this loop and every framework becomes a variation of it."
author: Asem Abdo
course: Agentic Engineering
lesson: The agent loop and tools
url: https://openplanr.dev/learn/agentic-engineering/agent-loop
updated: 2026-10-17
related:
  - https://openplanr.dev/learn/agentic-engineering/gently.md
  - https://openplanr.dev/learn/agentic-engineering/context-engineering.md
---

# The agent loop and its tools

By [Asem Abdo](https://www.linkedin.com/in/asemabdo/) · Updated 2026-10-17

*The engine*

Every agent, from a chatbot with search to Claude Code, is the same loop: the model reads its context, picks a tool, your code runs it, the result goes back into the context, repeat until a stop condition. Understand this loop and every framework becomes a variation of it.

## Anatomy of one loop

Level 1 · The rules

The model never runs anything. It asks; your harness decides and executes.

**Figure: The model proposes, the harness disposes. Every arrow is a place to add control..** The agent loop: context goes to the model, the model emits a tool call, the harness checks and runs it, the result is appended to context, and the loop repeats until a final answer or a budget stop.

**The four rules of the loop**

1. The model only **proposes** a tool call; the harness validates and executes it.
2. Every tool result, including errors, goes **back into the context**.
3. The loop ends on a **final answer** or a **budget**: max steps, tokens, time or money.
4. Anything not in the context **does not exist** for the model on the next step.

```python check=run title="A complete agent loop with a scripted model"
def fake_model(context):
    if not any(m["role"] == "tool" for m in context):
        return {"tool": "run_tests", "args": {}}
    return {"final": "Tests pass: 12 of 12."}

TOOLS = {"run_tests": lambda: "12 passed, 0 failed"}

def agent(task, max_steps=5):
    context = [{"role": "user", "content": task}]
    for step in range(max_steps):
        action = fake_model(context)
        if "final" in action:
            return action["final"], step + 1
        result = TOOLS[action["tool"]](**action["args"])
        context.append({"role": "tool", "content": result})
    raise RuntimeError("budget exhausted")

print(agent("Check that the test suite passes"))
```

```text title="Output (Python 3.14.8)"
('Tests pass: 12 of 12.', 2)
```

**Try it:** Point at the line that is the stop condition, the line that is the budget, and the line that turns a tool result into context. Those three lines are the whole agent.

## Tools are APIs for a model

Level 1 · The rules

A tool is a name, a description and an input schema. The model reads all three to decide when and how to call it, so they are documentation and interface at once.

```python check=run title="A tool definition the model will read"
import json

read_file = {
    "name": "read_file",
    "description": (
        "Read a UTF-8 text file from the repo. Use before editing. Returns at most 400"
        " lines."
    ),
    "input_schema": {
        "type": "object",
        "properties": {"path": {"type": "string", "description": "Repo-relative path"}},
        "required": ["path"],
    },
}
print(json.dumps(read_file["input_schema"]["required"]))
```

```text title="Output (Python 3.14.8)"
["path"]
```

When the model asks for a tool that does not exist, a harness without a check crashes. That is one of the most common agent failures: the model invents a plausible tool name.

```python check=err:ToolNotFoundError title="The model calls a tool that is not registered"
class ToolNotFoundError(Exception):
    pass

TOOLS = {"read_file": lambda path: "contents"}

def dispatch(call):
    if call["tool"] not in TOOLS:
        raise ToolNotFoundError(call["tool"])
    return TOOLS[call["tool"]](**call["args"])

dispatch({"tool": "open_file", "args": {"path": "README.md"}})
```

```text title="Traceback (Python 3.14.8)"
Traceback (most recent call last):
  File "main.py", line 11, in <module>
    dispatch({"tool": "open_file", "args": {"path": "README.md"}})
    ~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "main.py", line 8, in dispatch
    raise ToolNotFoundError(call["tool"])
ToolNotFoundError: open_file
```

> **Note:** **Senior habit:** never let a tool error crash the loop. Return it to the model as a tool result, for example "Unknown tool open_file. Available: read_file." The model reads it and corrects itself on the next step.

```python check=run title="Errors as observations: the loop recovers"
TOOLS = {"read_file": lambda path: "# README"}

def dispatch(call):
    if call["tool"] not in TOOLS:
        return (
            "error: unknown tool " + call["tool"] + ". Available: " + ", ".join(TOOLS)
        )
    return TOOLS[call["tool"]](**call["args"])

print(dispatch({"tool": "open_file", "args": {"path": "README.md"}}))
print(dispatch({"tool": "read_file", "args": {"path": "README.md"}}))
```

```text title="Output (Python 3.14.8)"
error: unknown tool open_file. Available: read_file
# README
```

## Why stop conditions and budgets are not optional

Level 2 · Why it works

A model can loop forever: retrying a failing command, re-reading the same file, or "almost done" for 80 steps. Only the harness can stop it.

```python check=err:BudgetExceededError title="A model that never finishes, stopped by a budget"
class BudgetExceededError(Exception):
    pass

def stuck_model(context):
    return {"tool": "run_tests", "args": {}}

def agent(max_steps=3):
    context = []
    for _ in range(max_steps):
        action = stuck_model(context)
        context.append("tests failed")
    raise BudgetExceededError(f"stopped after {max_steps} steps")

agent()
```

```text title="Traceback (Python 3.14.8)"
Traceback (most recent call last):
  File "main.py", line 14, in <module>
    agent()
    ~~~~~^^
  File "main.py", line 12, in agent
    raise BudgetExceededError(f"stopped after {max_steps} steps")
BudgetExceededError: stopped after 3 steps
```

- **Step budget:** Max loop iterations. Catches retry loops.
- **Token budget:** Max tokens per run. Catches context bloat.
- **Cost budget:** Max dollars per run. Catches expensive fan-out.
- **Time budget:** Wall-clock limit. Catches hung tools.

### Structured outputs (contract)

When another program consumes the model's answer, ask for JSON that matches a schema and validate it. Free text is for humans; schemas are for code.

```python check=err:SchemaValidationError title="Validate before you trust"
class SchemaValidationError(Exception):
    pass

def validate(obj, required):
    missing = [k for k in required if k not in obj]
    if missing:
        raise SchemaValidationError("missing: " + ", ".join(missing))
    return obj

model_output = {"title": "Add login", "points": 3}
validate(model_output, ["title", "points", "acceptance_criteria"])
```

```text title="Traceback (Python 3.14.8)"
Traceback (most recent call last):
  File "main.py", line 11, in <module>
    validate(model_output, ["title", "points", "acceptance_criteria"])
    ~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "main.py", line 7, in validate
    raise SchemaValidationError("missing: " + ", ".join(missing))
SchemaValidationError: missing: acceptance_criteria
```

## Trade-offs seniors talk about

Level 3 · Mastery

| Choice | Gain | Cost |
| --- | --- | --- |
| More tools | More capability | Every definition costs context on every call, and choice gets harder |
| Parallel tool calls | Lower latency | Harder to reason about ordering and side effects |
| Higher step budget | Solves longer tasks | Higher cost and more room to drift |
| Errors returned as results | Self-correction | Can loop on the same error without a budget |
| Strict schemas | Reliable integration | Some loss of nuance; needs retry on validation failure |

> **Watch out:** **The trap:** giving an agent twenty generic tools. Fewer, sharper tools with clear descriptions beat a large toolbox. Tool design is API design for a reader that takes every word literally.

## Predict the output

### Counting loop steps

Level 1.

```python
def model(context):
    return {"final": "done"} if len(context) == 2 else {"tool": "step"}

context = []
for step in range(10):
    action = model(context)
    if "final" in action:
        print("stopped at step", step + 1)
        break
    context.append("result")
```

1. stopped at step 3
2. stopped at step 2
3. stopped at step 10
4. stopped at step 1

**Answer:** option 1.

```text title="Output (Python 3.14.8)"
stopped at step 3
```

**Why:** Two tool results are appended in steps 1 and 2; on step 3 the context has length 2 and the model returns its final answer.

## Check your understanding

Levels 1 to 3.

**Q:** Who executes a tool call: the model or the harness?

**A:** The harness. The model only emits a structured request. That separation is what makes permissions, hooks and validation possible.

**Q:** Why return tool errors to the model instead of raising?

**A:** The model can read the error and correct itself on the next step. Raising ends the run and wastes the work so far. Pair it with a budget so a repeated error cannot loop forever.

**Q:** Name four budgets a harness should enforce.

**A:** Steps, tokens, cost and wall-clock time.
