The previous lesson ended with a question our single-step flow cannot answer: "Is Atlas over my plan's limit?". It takes two lookups against different sources — the documentation for the plan's limit, the API for the current tasks — and a comparison that only makes sense once both pieces of data are on the table. No fixed script written in advance solves this elegantly, because what to look up and in what order depends on the question. The solution is to let the model take the lead: call it in a loop, feeding it on each turn the results of what it requested, until it decides it can answer. That loop — reason, choose a tool, execute, observe, repeat — is what turns an LLM into an agent, and it is the most overloaded word in the industry explained in twenty lines of Python. In this lesson we write them: agent.py, with its exit conditions, its three tools (one of which will be the module 4 RAG, recycled as a tool), its first write action with human confirmation, and its traces. And we will close with the most important and least asked question: when not to use an agent.

Contents

  1. What turns an LLM into an agent: the reason-act-observe loop
  2. The loop in Python: agent.py and its two exit conditions
  3. DocuBot's three tools (and the RAG turned into a tool)
  4. Write actions: create_support_ticket and human confirmation
  5. Traces: seeing what the agent does step by step
  6. When NOT to use an agent: fixed pipeline vs agentic loop

What turns an LLM into an agent: the reason-act-observe loop

In 05-01 the flow was a score to be played: first call, execute tool, second call, done. It worked because we wrote the script ourselves. An agent reverses the casting: we write the loop and the model writes the script — it decides on each iteration whether it needs another tool, which one, with what arguments, and when it is finished. The structure is always the same:

flowchart TD
    P[User request] --> L["LLM call<br/>(full history + tools)"]
    L --> D{stop_reason}
    D -- "end_turn:<br/>the model considers the task done" --> R[Final answer to the user]
    D -- "tool_use:<br/>the model requests one or more tools" --> E["Your code executes<br/>(validating, with least privilege)"]
    E --> T["tool_result(s) appended to the history"]
    T --> C{"iterations < maximum?"}
    C -- "yes" --> L
    C -- "no" --> S["Emergency exit:<br/>report that the limit was reached"]

Every turn of the loop is a reason → act → observe cycle: the model reasons over the history (which grows with each result), acts by requesting a tool, and observes the tool_result in the next call. The agent's memory between steps is simply the messages list — the same conversation mechanics as 03-03, except that here the turns are generated by the process itself rather than by a human.

Two consequences of this design worth internalizing before writing code:

  • The agent is non-deterministic twice over: in the text it generates (we already knew that) and in the path it takes. Two runs of the same question can use the tools in a different order, or a different number of times. This is its strength (flexibility) and its bill (predictability), and it shapes the entire final section.
  • Every iteration is a full API call with the accumulated history: cost and latency grow with each turn (and the history gets fatter with each tool_result). An agent that takes five turns costs, at minimum, five calls — remember that when we revisit the cost projection from 03-04.

The loop in Python: agent.py and its two exit conditions

The complete loop. It is surprisingly short — the robustness is inherited, as always, from the earlier layers (call_with_retries from 03-02, run_tool from 05-01):

# agent.py — DocuBot's agentic loop
import json
from client import MODEL, extract_text
from retries import call_with_retries
from prompts import SYSTEM_DOCUBOT_AGENT
from tools import TOOLS, run_tool
from traces import log_step

MAX_ITERATIONS = 8   # safety belt: never an infinite loop

LIMIT_MESSAGE = (
    "I couldn't complete the request within the maximum number of allowed steps. "
    "Try phrasing it more specifically or splitting it into parts."
)

def agent_loop(question: str,
               tools: list[dict] = TOOLS,
               max_iterations: int = MAX_ITERATIONS) -> str:
    """Reason -> act -> observe, until the model finishes or the limit runs out."""
    messages = [{"role": "user", "content": question}]

    for iteration in range(1, max_iterations + 1):
        # REASON: the model sees the entire history and the tools
        response = call_with_retries(
            model=MODEL,
            max_tokens=1024,
            system=SYSTEM_DOCUBOT_AGENT,
            tools=tools,
            messages=messages,
        )

        # Natural exit condition: the model considers the task done
        if response.stop_reason != "tool_use":
            log_step(iteration, "final_answer", None)
            return extract_text(response)

        # ACT: execute ALL the tool_use blocks in the turn (there may be several)
        messages.append({"role": "assistant", "content": response.content})
        results = []
        for block in response.content:
            if block.type != "tool_use":
                continue                      # text blocks are not executed
            log_step(iteration, block.name, block.input)
            try:
                output = run_tool(block.name, block.input)
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": json.dumps(output, ensure_ascii=False),
                })
            except Exception as e:
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": str(e),
                    "is_error": True,        # the model will decide how to react
                })

        # OBSERVE: the results enter the history for the next turn
        messages.append({"role": "user", "content": results})

    # Emergency exit condition: the safety belt
    log_step(max_iterations, "limit_reached", None)
    return LIMIT_MESSAGE

The two exit conditions are the anatomy of the loop and deserve names of their own:

Exit Mechanism What it means
Natural stop_reason != "tool_use" The model, after observing the results, decided it can answer. This is the happy — and usual — ending.
Emergency max_iterations exhausted Something is wrong: the model is going in circles (retrying a failing tool, or "hesitating" between two), and without a limit the loop would spin forever, burning money on every turn.

The value of MAX_ITERATIONS is chosen by looking at your real cases: for DocuBot, with three tools and questions that rarely need more than 2-3 steps, 8 gives headroom without allowing disasters. It's the same spirit as the circuit breaker in 03-02 — a mechanism you hope never fires, and which is not optional.

The system prompt is still missing. SYSTEM_DOCUBOT_AGENT extends the one from 05-01 with the rules of when to use what, and brings back our two sacred phrases:

# prompts.py (added in 05-02)
SYSTEM_DOCUBOT_AGENT = """You are DocuBot, Nubelia's internal assistant. You only answer questions about Nubelia.

Tools and when to use them:
- Questions about how Nubelia works (plans, permissions, integrations, API...):
  ALWAYS use search_documentation before answering, and cite the sources.
- Real-time data about the user's projects: use query_project.
- Creating a support ticket: use create_support_ticket ONLY if the user asks
  for it explicitly. Never create one on your own initiative.

Rules:
- If search_documentation returns nothing relevant, answer exactly:
  "I can't find that information in the available documentation."
- Never invent project data or documentation content.
- Answer in the user's language, with a professional, concise tone."""

DocuBot's three tools (and the RAG turned into a tool)

The agent debuts a new arsenal: query_project (05-01) is joined by two new tools. The first is an important moment in the course: the module 4 RAG pipeline becomes a tool. We rewrite nothing — search_documentation is a thin wrapper over retrieve() and build_documentation() from rag.py:

# tools.py (extended in 05-02)
from rag import retrieve, build_documentation, RELEVANCE_THRESHOLD
import tickets

def search_documentation(query: str) -> dict:
    """RAG as a tool: retrieve chunks and return them with their sources."""
    chunks = retrieve(query)                     # k=4, docs_nubelia collection
    if not chunks or chunks[0]["similarity"] < RELEVANCE_THRESHOLD:
        return {"result": "no_relevant_information", "chunks": ""}
    return {
        "result": "ok",
        "chunks": build_documentation(chunks),   # numbered, with sources
        "sources": sorted({c["url"] for c in chunks}),
    }

Notice the change of perspective relative to 04-04: there, answer_with_rag() owned the flow (retrieve → augment → generate). Here the generation is orchestrated by the loop, so the tool only retrieves and augments — it returns the chunks and lets the agent decide what to do with them (it may also need to query the API before writing the answer). The threshold guardrail is still standing: if nothing is relevant, the tool says so with an unambiguous signal (no_relevant_information), and the system prompt orders the model to reply with the usual anti-hallucination phrase. The defenses of 04-04 are not lost; they change address.

The definitions of the three tools, with their JSON Schemas:

TOOLS = [
    {
        "name": "search_documentation",
        "description": (
            "Searches Nubelia's official documentation and returns the most "
            "relevant chunks with their sources. Use it ALWAYS before answering "
            "questions about how Nubelia works: plans, limits, permissions, "
            "billing, integrations, API. If it returns 'no_relevant_information', "
            "do not invent the answer."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string",
                          "description": "Search query, specific and self-contained."},
            },
            "required": ["query"],
        },
    },
    {   # unchanged since 05-01
        "name": "query_project",
        "description": "Queries the REAL-TIME status of one of the user's projects...",
        "input_schema": {
            "type": "object",
            "properties": {"name": {"type": "string"}},
            "required": ["name"],
        },
    },
    {
        "name": "create_support_ticket",
        "description": (
            "Creates a ticket in Nubelia's support system. This is a WRITE "
            "ACTION: use it only when the user explicitly asks to open a "
            "ticket, never on your own initiative. Summarize the problem in the "
            "title and provide the detail in the description."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "title": {"type": "string", "description": "Brief summary of the problem."},
                "description": {"type": "string", "description": "Problem detail and context."},
                "priority": {"type": "string", "enum": ["low", "medium", "high"]},
            },
            "required": ["title", "description", "priority"],
        },
    },
]

And the dispatcher grows with the two new entries (using a dict of functions, which scales better than the if chain from 05-01):

_REGISTRY = {
    "search_documentation":  lambda args: search_documentation(args["query"]),
    "query_project":         lambda args: nubelia_api.get_project(args["name"]),
    "create_support_ticket": lambda args: tickets.create_with_confirmation(args),
}

def run_tool(name: str, arguments: dict):
    if name not in _REGISTRY:
        raise ValueError(f"Unknown tool: {name}")
    return _REGISTRY[name](arguments)

Write actions: create_support_ticket and human confirmation

Until now all of DocuBot's tools were read tools: if the model got it wrong, the damage was a bad answer. create_support_ticket crosses a new line — it modifies the world (it creates something in another system, bothers a human team, and can't be undone by refreshing the page). For actions like this, two principles we already sketched in 05-01 stop being advice and become design:

  • Least privilege: this tool's credential can only create tickets — not read them all, not close them, not touch anything else. If tomorrow a malicious prompt fools the agent (the star attack of 06-01), the blast radius is whatever you bounded today.
  • Human confirmation (human-in-the-loop): the model proposes the action with all its arguments, but a human approves it before it happens. The decision to act on the world is always human; the agent loads the gun, it doesn't pull the trigger.

The pattern, in its minimal console version:

# tickets.py — write action with human confirmation
_NEXT_ID = 1001

def create_with_confirmation(args: dict) -> dict:
    """Shows the proposed action and only executes it if a human approves."""
    print("\n--- The agent proposes creating a ticket ---")
    print(f"  Title:       {args['title']}")
    print(f"  Priority:    {args['priority']}")
    print(f"  Description: {args['description']}")
    if input("Confirm creation? (y/n): ").strip().lower() != "y":
        # The rejection is NOT a system error: it's information for the model
        return {"result": "cancelled",
                "detail": "The user did not authorize creating the ticket."}
    return _create_ticket(args)                 # the real API call would go here

def _create_ticket(args: dict) -> dict:
    global _NEXT_ID
    ticket_id = f"NUB-{_NEXT_ID}"
    _NEXT_ID += 1
    return {"result": "created", "id": ticket_id, "priority": args["priority"]}

The subtlest detail is in the rejection: it is returned as a normal tool_result (not is_error), because nothing failed — the human exercised their right of veto, and the model must find out so it can react well ("Understood, I haven't created the ticket. Would you like me to change anything before trying again?"). The input() is the toy version of the pattern; in a real application the confirmation would be a button in the interface or an asynchronous approval step, but the architecture — the write tool does not execute without a human yes — is exactly the same. In 06-01 we'll see why this line of defense matters even more than it seems today.

Traces: seeing what the agent does step by step

A fixed pipeline is debugged by reading the code: the path is always the same. An agent is not — the path is decided by the model on every run, so if you don't log what it did, you don't know what it did. Hence the log_step sprinkled through the loop:

# traces.py — minimal version (06-04 will professionalize it)
import json, time

def log_step(iteration: int, action: str, arguments: dict | None):
    stamp = time.strftime("%H:%M:%S")
    args = json.dumps(arguments, ensure_ascii=False) if arguments else ""
    print(f"[{stamp}] step {iteration}: {action} {args}")

With this, the question that opened the lesson stops being a black box. agent_loop("Is Atlas over my plan's limit?") produces a trace like this one:

[10:42:01] step 1: search_documentation {"query": "task limit per project by plan"}
[10:42:04] step 2: query_project {"name": "Atlas"}
[10:42:07] step 3: final_answer

And the final answer combines both sources: "Your Professional plan allows up to 200 open tasks per project (source: /docs/plans). Atlas currently has 27 open, so you're well below the limit." Read it again slowly: the first half comes from the RAG, the second from the API, and the comparison was made by the model on turn 3 as it observed both results in the history. No code of ours knew this question needed two tools — that's the agent.

Traces are also your pathology detector: a trace where query_project appears four times with the same argument betrays a model stuck retrying; one where search_documentation doesn't appear before an answer about plans betrays a system prompt that isn't holding the model in check. This seed — logging every decision with its timestamp — will germinate in 06-04 as real observability (structured traces, tokens and cost per step, dashboards); for now, an honest print is worth gold.

When NOT to use an agent: fixed pipeline vs agentic loop

The closing question, and the most profitable one in the lesson. Now that DocuBot can run as an agent, should it always? No — and the table explains why the answer_with_rag() from 04-04 doesn't retire:

Criterion Fixed pipeline (e.g. answer_with_rag) Agent (agent_loop)
Cost Known and constant: 1 LLM call (+embeddings) Variable: N calls, with a growing history in each
Latency Predictable (~one call) Unpredictable: every turn adds seconds
Predictability Same path always; testable as a function The path changes between runs; testing costs more (06-03)
Debugging Read the code Read the traces
Flexibility Only solves the case it was designed for Combines tools for unforeseen cases
Where it shines Known task with known steps Task whose plan depends on the request

The practical rule that follows: use the simplest tool that solves the problem. "How do I set up SSO?" is a pure documentation question: answer_with_rag() solves it with one call, fixed cost and the usual validated JSON contract — sending it to the agent is paying for flexibility that goes unused. "Is Atlas over my plan's limit?" needs a dynamic plan: agent. A good production DocuBot would probably combine both — a classifier in front (does classification_prompt() from 02-02 ring a bell?) that routes the simple to the pipeline and the compound to the agent. That orchestration of flows with branches is exactly the territory of frameworks, and it is the next lesson.

Common Mistakes and Tips

  • A loop with no iteration limit. "The model always finishes" is true until a broken tool traps it in a retry cycle. max_iterations is like an HTTP request timeout: non-negotiable.
  • Blowing up the loop when a tool fails. An uncaught exception kills the agent mid-task. Catch it and return it as a tool_result with is_error — the model often recovers on its own (tries another argument, or another tool) and that resilience is free.
  • Processing only the first tool_use of the turn. The model can request several tools at once (our Atlas example sometimes asks for documentation and project in the same turn). Every tool_use_id demands its tool_result; if one is missing, the API rejects the next call.
  • Write tools with no human brake. If the agent can create/modify/delete without confirmation, you've delegated an irreversible decision to a non-deterministic process. The agent proposes, the human decides — especially while you don't yet have the evaluation (06-03) and the defenses (06-01) that would justify loosening the brake in bounded cases.
  • Turning everything into an agent. The symptom: latencies that triple and bills that don't square with the 03-04 projection, for questions a fixed pipeline solved just as well. Review the table; the agent is an excellent hammer, but not everything is a nail.
  • Traces that arrive late. Adding logging after the first strange behavior means debugging blind the first time. The trace is written along with the loop, not after the incident.

Exercises

  1. Traces with cost. Extend log_step (and the loop) to also log, on each LLM call, the input and output tokens (response.usage.input_tokens / output_tokens) and the estimated cost with estimate_cost() from pricing.py (03-04). Run the Atlas question and answer: what share of the total cost went to the last turn, and why is it the most expensive one?
  2. A more dignified emergency exit. When max_iterations runs out, LIMIT_MESSAGE makes no use of anything that was discovered. Modify the loop so that, when the limit is exhausted, it makes one last call without tools (hint: tool_choice: {"type": "none"}) asking the model to summarize what it has found so far and what was missing.
  3. Pipeline or agent? For each request, decide which path a well-designed DocuBot would take and justify it in one sentence using the criteria from the table: (a) "What payment methods do you accept?"; (b) "Compare the open tasks across my three projects and tell me which one is doing worst"; (c) "How do I create a webhook?"; (d) "Boreal's webhook has stopped working; if it's a known issue tell me, and if not, open a ticket".

Solutions

  1. Minimal changes:
# In the loop, after each call:
cost = estimate_cost(response.usage.input_tokens, response.usage.output_tokens)
log_step(iteration, "llm_call", {
    "input": response.usage.input_tokens,
    "output": response.usage.output_tokens,
    "cost": round(cost, 5),
})

The last turn is the most expensive in input tokens because the history accumulates all the previous tool_results (the documentation chunks weigh especially heavily); it typically concentrates 40-60% of the total cost. This is the economic reason behind "use the simplest tool": every avoided turn saves the most expensive of the calls that would have come after it. (And yes: the stable prefix — system prompt + tool definitions — is an ideal candidate for the prompt caching of 03-04.)

  1. Replace the final return LIMIT_MESSAGE with:
    messages.append({"role": "user", "content": (
        "You have reached the maximum number of steps. Do not use any more tools: "
        "summarize what you have found so far, what you could not confirm, "
        "and what the user could do next."
    )})
    final = call_with_retries(
        model=MODEL, max_tokens=1024,
        system=SYSTEM_DOCUBOT_AGENT,
        tools=TOOLS,
        tool_choice={"type": "none"},   # forbidden to request more tools
        messages=messages,
    )
    return extract_text(final)

Graceful degradation, agentic edition: the same philosophy as FALLBACK_RESPONSE in 03-02 — if you can't give everything, give the best you have and be honest about what's missing.

  1. (a) Pipeline (answer_with_rag): pure documentation, known path, fixed cost. (b) Agent: a dynamic number of steps (list projects, query each one, compare) that no fixed pipeline anticipates elegantly. (c) Pipeline: another documentation question in technical disguise. (d) Agent, and a textbook one: it requires searching the documentation (known issue?), maybe querying the project, and conditionally a write action with human confirmation — it is literally the reason-act-observe loop with the lesson's three tools.

Conclusion

Twenty lines of loop have changed DocuBot's nature. Let's review what was built: the reason → act → observe cycle implemented in agent.py::agent_loop(), with the natural exit via stop_reason and the safety belt of max_iterations; three tools where before there was one — search_documentation (the module 4 RAG reconverted into a tool: retrieve and augment, leaving generation to the loop), query_project (05-01) and create_support_ticket, the course's first write action, debuting the human-in-the-loop pattern: the agent proposes with complete arguments, the human confirms, and the rejection returns to the model as information, not as an error; traces that turn every run into a readable story (the seed of 06-04); and a table that vaccinates us against enthusiasm — the fixed pipeline of 04-04 remains the best answer for the simple question, and the agent is reserved for when the plan depends on the request. Now then: we wrote all of this by hand — the loop, the tool registry, the memory in messages, the traces, the confirmation. That was deliberate (there is no better way to understand what an agent does than to write one), but you have surely wondered whether an off-the-shelf piece doesn't already exist with all of this solved. It exists — dozens exist, and that is precisely the problem. The next lesson brings order: what orchestration frameworks solve, what they cost in abstraction and dependencies, how LangChain/LangGraph, LlamaIndex, Haystack and the providers' agent SDKs compare, what that standard called MCP everyone talks about is — and why, knowing all of it, DocuBot will keep its hand-written loop.

© Copyright 2026. All rights reserved