# Agents

> Build a bounded agent loop with tools, plan and manage context, add guardrails, and decide when a fixed workflow is better.

AI Engineer roadmap · Agents & production · 20 min read · https://levelcode.org/roadmaps/ai-engineer/agents

In the tool-use lesson you sent a request, ran the tool the model asked for, and sent the result back. An **agent** is that loop left running: the model decides which tool to call next, reads the result, and keeps going until it judges the task done. That autonomy is powerful for open-ended tasks and expensive and risky everywhere else. This lesson covers when an agent is the right design, how to build a robust loop, how to design tools and manage context for long runs, and the guardrails that keep an agent from running away with your budget or your data.

## Workflows vs agents, and when not to build one

There is a spectrum of designs, and it pays to pick the simplest one that works.

- **Single call**: one prompt, one answer. Classification, extraction, rewriting.
- **Workflow**: *your code* decides the steps; the model fills each one. Chaining (outline, then draft, then edit), routing (classify a ticket, then use the prompt for that category), or fan-out (summarise 20 documents in parallel, then merge).
- **Agent**: *the model* decides the steps, choosing tools in a loop until done.

```python
# A workflow: the control flow is ordinary code you can read and test.
def handle_ticket(ticket: str) -> str:
    category = classify(ticket)                 # one call, fixed set of labels
    if category == "refund":
        order = lookup_order(ticket)            # plain code, no model involved
        return draft_reply(ticket, order, template="refund")
    return draft_reply(ticket, None, template="general")
```

Workflows are cheaper, faster, and predictable: you can unit-test each step. A one-label step like `classify` doesn't even need a generative model; a [decision model](https://levelcode.org/concepts/decision-models) returns a probability per label in tens of milliseconds. Agents earn their cost when **you cannot write the steps down in advance**: debugging an unfamiliar codebase, researching a question across many sources, or handling requests where each answer changes what to do next.

Do not build an agent when:

- The steps are known. Encode them as a workflow.
- A user is waiting on a tight latency budget. Each loop turn is a full model call.
- Mistakes are costly and nobody reviews the result.
- Deterministic code can do it. A model that calls `sum` five times is a slow calculator.

## The agent loop

The loop is short. The details that make it robust are what matter: a turn limit, checking `stop_reason` on every turn, sending *all* tool results back in one message, and turning tool failures into error results rather than crashes.

Here is a read-only research agent that answers questions about a folder of files. First, the tools:

```python
from pathlib import Path

ROOT = Path("./project").resolve()

def safe_path(rel: str) -> Path:
    p = (ROOT / rel).resolve()
    if not p.is_relative_to(ROOT):
        raise ValueError(f"{rel!r} is outside the project")
    return p

def list_files(directory: str) -> str:
    files = sorted(str(p.relative_to(ROOT)) for p in safe_path(directory).rglob("*") if p.is_file())
    return "\n".join(files[:200]) or "(no files)"

def search(query: str) -> str:
    hits = []
    for p in sorted(ROOT.rglob("*")):
        if not p.is_file():
            continue
        try:
            lines = p.read_text(encoding="utf-8").splitlines()
        except UnicodeDecodeError:
            continue
        for n, line in enumerate(lines, start=1):
            if query.lower() in line.lower():
                hits.append(f"{p.relative_to(ROOT)}:{n}: {line.strip()[:200]}")
                if len(hits) == 20:
                    return "\n".join(hits) + "\n(truncated at 20 matches)"
    return "\n".join(hits) or "no matches"

def read_file(path: str) -> str:
    lines = safe_path(path).read_text(encoding="utf-8").splitlines()
    more = f"\n... ({len(lines) - 400} more lines)" if len(lines) > 400 else ""
    return "\n".join(lines[:400]) + more

HANDLERS = {"list_files": list_files, "search": search, "read_file": read_file}

def tool(name: str, description: str, param: str, param_desc: str) -> dict:
    return {
        "name": name,
        "description": description,
        "strict": True,
        "input_schema": {
            "type": "object",
            "properties": {param: {"type": "string", "description": param_desc}},
            "required": [param],
            "additionalProperties": False,
        },
    }

TOOLS = [
    tool("list_files", "List files under a directory. Call this first to see what exists.",
         "directory", "Path relative to the project root, e.g. '.' or 'src'"),
    tool("search", "Case-insensitive text search across all files; returns up to 20 'path:line: text' matches. "
         "Call this to find which files mention a name or term.", "query", "Literal text to find"),
    tool("read_file", "Read the first 400 lines of a text file. Call this on files found by list_files or search.",
         "path", "Path relative to the project root"),
]
```

Then the loop:

```python
import anthropic

client = anthropic.Anthropic()

SYSTEM = """You answer questions about the project in the provided tools' file system.
Work step by step: look around, search, then read the most promising files.
Stop as soon as you can answer. Cite files as path:line. If the files do not
contain the answer, say so instead of guessing."""

def run_agent(task: str, max_turns: int = 15) -> str:
    messages = [{"role": "user", "content": task}]
    for turn in range(max_turns):
        response = client.messages.create(
            model="claude-sonnet-5-5",
            max_tokens=16000,
            system=SYSTEM,
            tools=TOOLS,
            messages=messages,
        )
        # Append the full content: it carries tool_use (and thinking) blocks the API needs back.
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "refusal":
            return "The model declined this task."
        if response.stop_reason == "max_tokens":
            return "Stopped: the response was cut off (raise max_tokens)."
        calls = [b for b in response.content if b.type == "tool_use"]
        if not calls:
            return "".join(b.text for b in response.content if b.type == "text")

        results = []
        for call in calls:
            print(f"turn {turn}: {call.name}({call.input})")
            try:
                output = HANDLERS[call.name](**call.input)
                results.append({"type": "tool_result", "tool_use_id": call.id, "content": output})
            except Exception as exc:  # report the failure to the model; let it adapt
                results.append({"type": "tool_result", "tool_use_id": call.id,
                                "content": f"Error: {exc}", "is_error": True})
        messages.append({"role": "user", "content": results})  # ALL results, ONE message
    return f"Stopped: reached the {max_turns}-turn limit before finishing."

print(run_agent("Where is the request timeout configured, and what is its default?"))
```

A typical run looks like this:

```text
turn 0: search({'query': 'timeout'})
turn 1: read_file({'path': 'src/config.py'})
turn 1: read_file({'path': 'src/http_client.py'})
The request timeout is set by REQUEST_TIMEOUT_SECONDS in src/config.py:14,
defaulting to 30 seconds; src/http_client.py:22 passes it to every request.
```

The diagram traces that run turn by turn. Compared with the single round trip in the tool-use lesson, look for three things: the model, not your code, picks each next tool; one turn can request several tools; and the loop ends either when a response has no tool calls or when one of your limits trips.

```sequence
title: The agent loop for one research task
participants: Agent loop (your code), Claude API, Tools (your code)
Agent loop (your code) -> Claude API: task + system + tools
> Turn 0. Every request sends the whole conversation so far; the API keeps no state.
Claude API --> Agent loop (your code): tool_use: search("timeout")
> The model chose the first step. Your loop checks stop_reason (refusal, max_tokens) before running anything.
Agent loop (your code) -> Tools (your code): search("timeout")
Tools (your code) --> Agent loop (your code): up to 20 path:line matches
Agent loop (your code) -> Claude API: history + tool_result
> Turn 1. The result is appended as a user message; failures would go back as is_error results.
Claude API --> Agent loop (your code): tool_use × 2: read_file
> Having seen the matches, the model asks for two files at once.
Agent loop (your code) -> Tools (your code): read_file × 2
Tools (your code) --> Agent loop (your code): first 400 lines of each
Agent loop (your code) -> Claude API: history + 2 tool_results
> Both results go back together in ONE user message.
Claude API --> Agent loop (your code): text answer, no tool_use
> No tool calls means the model judges the task done, so the loop returns the text.
note Agent loop (your code), Tools (your code): Bounded by max_turns and your budgets
```

Note turn 1: the model requested two reads at once. Both results go back in a single user message; splitting them into separate messages breaks the expected turn structure. A truncated response (`max_tokens`) may contain a half-written tool input, so never execute tools from it.

> [!TIP]
> The Python SDK's tool runner (`client.beta.messages.tool_runner`, in beta) runs this loop for you: decorate plain functions with `@beta_tool` and it generates schemas from type hints and docstrings, calls your functions and feeds results back. Write the loop by hand once so you understand it, then use the runner where it fits.

## Designing tools for an agent

An agent is only as good as its tools. The model sees nothing but each tool's name, description and schema, so treat them as an interface designed for a new colleague:

- **Few, focused tools.** Three tools that cover the task beat fifteen overlapping ones. Every extra tool is another choice to get wrong.
- **Say when to call it**, not just what it does: "Call this first to see what exists."
- **Constrain inputs.** Use `enum` for fixed choices, `strict: True` so arguments always match the schema, and validate anyway in code.
- **Return compact, useful output.** Truncate long results and say so ("truncated at 20 matches"), so the model knows to narrow its search instead of assuming it saw everything.
- **Make errors actionable.** `"Error: 'src/confg.py' not found. Did you mean src/config.py?"` lets the model recover; a stack trace wastes context.
- **Separate reading from acting.** Read-only tools can run freely. Tools with side effects (write, send, delete, pay) deserve their own narrowly typed tools so your code can gate them.

## Planning and managing context

Current Claude models think before acting by default (adaptive thinking) and interleave that reasoning between tool calls, so you rarely need elaborate planning prompts. What helps is stating the strategy and the stopping condition, as in `SYSTEM` above. For long tasks, give the agent a way to keep its plan visible: a `update_plan` tool that stores a checklist your code echoes back, or simply asking for a short plan in the first turn. The `output_config={"effort": ...}` parameter trades depth of reasoning against tokens and speed; lower it for simple tasks.

Context is the agent's working memory, and it fills up. Every turn re-sends the whole conversation, so a 20-turn run that reads ten files pays for those files on every subsequent turn. Control it:

- **Truncate tool output** at the source, as `read_file` does.
- **Use prompt caching** so the repeated prefix (tools, system prompt, earlier turns) is billed at a fraction of the price. The cost and latency lesson covers how.
- **Delegate to sub-agents.** For a subtask like "read these 12 files and summarise what each does", run a separate agent with a fresh context and return only its short summary to the main loop. The orchestrator stays focused and cheap.
- **Summarise and restart** very long sessions: ask for a summary of findings so far, then continue in a new conversation that starts from it.

## Guardrails

An agent loop is a program that decides its own next step, so bound it like one:

| Guardrail | Why |
| --- | --- |
| Turn limit (`max_turns`) | Stops infinite loops |
| Token or cost budget | Sum `response.usage` each turn; stop when over |
| Wall-clock timeout | A user-facing agent cannot run for 10 minutes |
| Repeated-call detection | Same tool with the same input three times means it is stuck |
| Human approval for side effects | Irreversible actions need a person |
| Sandboxing and least privilege | Limit what a confused or manipulated agent can reach |
| Full trace logging | You cannot debug what you did not record |

Approval belongs in your tool-execution code, outside the model's control:

```python
import json

NEEDS_APPROVAL = {"write_file", "send_email"}

def execute(call) -> dict:
    if call.name in NEEDS_APPROVAL:
        print(f"Agent wants to run {call.name}:\n{json.dumps(call.input, indent=2)}")
        if input("Allow? [y/N] ").strip().lower() != "y":
            return {"type": "tool_result", "tool_use_id": call.id,
                    "content": "The user declined this action.", "is_error": True}
    return {"type": "tool_result", "tool_use_id": call.id,
            "content": HANDLERS[call.name](**call.input)}
```

Show the user the exact arguments, not the model's description of them. A declined action becomes an ordinary tool result, so the agent can explain or try another approach.

> [!IMPORTANT]
> Evaluate agents on outcomes. Run the task in a disposable workspace and check the end state (the answer is correct, the tests pass, nothing outside the sandbox changed), and track turns, tool calls and cost per task. A transcript that looks busy is not evidence of success.

## Practice

Extend the research agent:

1. Add a `write_note(path, text)` tool that writes a Markdown file under `ROOT / "notes"` only, and requires human approval.
2. Add a cost budget: stop with a message when the run exceeds 200,000 total input + output tokens.
3. Add repeated-call detection: if the same tool is called with identical input three times, stop and return what the agent has so far.
4. Ask it: "Summarise how configuration is loaded and save the summary to notes/config.md."

**Solution**

```python
import json
from collections import Counter

NOTES = (ROOT / "notes").resolve()

def write_note(path: str, text: str) -> str:
    target = (NOTES / path).resolve()
    if not target.is_relative_to(NOTES) or target.suffix != ".md":
        raise ValueError("notes must be .md files inside notes/")
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(text, encoding="utf-8")
    return f"wrote {len(text)} characters to notes/{path}"

HANDLERS["write_note"] = write_note
TOOLS.append({
    "name": "write_note",
    "description": "Save a Markdown note under notes/. Call only when the user asks you to save something.",
    "strict": True,
    "input_schema": {
        "type": "object",
        "properties": {
            "path": {"type": "string", "description": "File name inside notes/, e.g. 'config.md'"},
            "text": {"type": "string", "description": "Full Markdown content"},
        },
        "required": ["path", "text"],
        "additionalProperties": False,
    },
})
NEEDS_APPROVAL = {"write_note"}

def run_agent(task: str, max_turns: int = 15, token_budget: int = 200_000) -> str:
    messages = [{"role": "user", "content": task}]
    used = 0
    seen = Counter()
    for _ in range(max_turns):
        response = client.messages.create(
            model="claude-sonnet-5-5", max_tokens=16000,
            system=SYSTEM, tools=TOOLS, messages=messages,
        )
        used += response.usage.input_tokens + response.usage.output_tokens
        messages.append({"role": "assistant", "content": response.content})
        if response.stop_reason in ("refusal", "max_tokens"):
            return f"Stopped: {response.stop_reason}"
        calls = [b for b in response.content if b.type == "tool_use"]
        if not calls:
            return "".join(b.text for b in response.content if b.type == "text")
        if used > token_budget:
            return f"Stopped: token budget exceeded ({used:,} tokens)."
        results = []
        for call in calls:
            key = (call.name, json.dumps(call.input, sort_keys=True))
            seen[key] += 1
            if seen[key] >= 3:
                return f"Stopped: {call.name} repeated with the same input; agent appears stuck."
            try:
                results.append(execute(call))
            except Exception as exc:
                results.append({"type": "tool_result", "tool_use_id": call.id,
                                "content": f"Error: {exc}", "is_error": True})
        messages.append({"role": "user", "content": results})
    return "Stopped: turn limit reached."
```

The budget check sits after the assistant turn is recorded, and every early return happens *before* tools run, so nothing executes after a limit trips. `write_note` validates its own path even though the model was told the rule: the tool enforces it, the prompt only describes it. When prompted to save, you see the full note text before approving.

## Key takeaways

- An agent is a model choosing tools in a loop; use one only when the steps cannot be written down in advance, and prefer workflows otherwise.
- A robust loop has a turn limit, checks `stop_reason` every turn (`refusal`, `max_tokens`), returns all tool results in one user message, and reports tool failures as `is_error` results.
- Design few, well-described, strictly typed tools that return compact, actionable output; keep side-effecting actions in separate tools.
- Manage context by truncating results, caching the prefix, and delegating subtasks to sub-agents with fresh contexts.
- Bound every agent with budgets, timeouts, loop detection, sandboxing and human approval for irreversible actions, and evaluate it on end-state outcomes.

## Quiz

1. You need to summarise each incoming support ticket and tag it with one of five categories. Which design fits best?

   A. An autonomous agent with a dozen tools
   B. A single call or short fixed workflow, because the steps are known in advance
   C. A multi-agent system with an orchestrator and workers
   D. An agent with no turn limit so it can be thorough

   **Answer: B.** The steps never change, so a workflow (or one call with structured output) is cheaper, faster and testable. Agents are for tasks whose steps depend on what is discovered along the way.

2. The model's response contains three tool_use blocks. How do you send the results back?

   A. Three separate user messages, one per result
   B. Only the first result; the model will ask again for the others
   C. One user message containing three tool_result blocks, each with its matching tool_use_id
   D. One assistant message containing the three results

   **Answer: C.** All results for one assistant turn go back together in a single user message, each tool_result referencing its tool_use_id. Splitting them across messages breaks the expected turn structure.

3. A tool call raises FileNotFoundError. What should the loop do?

   A. Return a tool_result with is_error: true and a helpful message, and continue the loop
   B. Crash the agent so a developer notices
   C. Silently drop the tool call and continue
   D. Restart the conversation from the beginning

   **Answer: A.** An error result tells the model what went wrong so it can try a different path. Dropping the result leaves a tool_use without its tool_result, which the API rejects, and crashing wastes the work done so far.

4. Where should the human-approval check for a send_email tool live?

   A. In the system prompt: 'Always ask before sending email'
   B. In the tool description, so the model knows to be careful
   C. Nowhere; strict: true already guarantees the arguments are safe
   D. In your tool-execution code, which shows the exact arguments and only runs the tool if the user confirms

   **Answer: D.** Prompts describe rules; code enforces them. A confused or manipulated model can ignore an instruction, but it cannot bypass a check in the code that executes its tool calls. strict: true guarantees schema shape, not intent.
