Course ES

Chapter 20 of 22 · advanced

A single-loop agent with tools

What this session covers

Back in week 6 you wrote a tool loop, and I told you it had a name people charge money for. Here's that bill coming due: the loop is an agent. Strip the marketing off and an agent is three things — a goal (the system prompt that says what it's for and how to behave), a set of tools it can call, and a bounded loop that runs whatever the model asks for until the model stops asking and hands back an answer. That's the whole idea. Every framework you'll end up evaluating — LangGraph, the OpenAI Agents SDK, CrewAI, whatever ships next quarter — is conveniences stacked on those three pieces. Write the pieces yourself once and the frameworks stop looking like magic.

I don't push the build-it-yourself line out of purity. I push it because you can't debug what you didn't write. When an agent misbehaves — loops forever, calls the wrong tool, ignores a result it just got back — the fix depends on knowing exactly which part broke, and you only know that if you wrote the loop. So this hour is the loop, plus the handful of design decisions that separate a demo agent from one I'd actually trust: tools that are safe to hand a model, a hard cap on steps, and results the model can't argue its way around.

OpenAI

Tools first, because an agent is exactly as capable as its tools and exactly as safe as the worst one you hand it. The calculator below parses expressions with Python's AST and whitelists the operators instead of calling eval — so a tool the model is driving can't be talked into running arbitrary code.

# An agent is only as capable as its tools — and only as safe as its worst one.
# The calculator whitelists operators through the AST instead of calling eval(),
# so a tool the model controls can't run arbitrary code.
_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 _eval(node):
    if isinstance(node, ast.Constant):
        return node.value
    if isinstance(node, ast.BinOp):
        return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
    if isinstance(node, ast.UnaryOp):
        return _OPS[type(node.op)](_eval(node.operand))
    raise ValueError("unsupported expression")


def calculate(expression: str) -> dict:
    try:
        return {"result": _eval(ast.parse(expression, mode="eval").body)}
    except Exception as e:
        return {"error": str(e)}


_FACTS = {"speed_of_light_m_per_s": 299792458, "earth_radius_km": 6371,
          "seconds_per_day": 86400, "moon_distance_km": 384400}


def lookup(key: str) -> dict:
    return {"value": _FACTS.get(key), "known_keys": list(_FACTS)}


TOOLS_IMPL = {"calculate": calculate, "lookup": lookup}

Then the agent itself — a goal in the system prompt, the toolset, and the bounded loop. Treat the step cap as mandatory. It's the difference between "the agent got confused" and "the agent got confused and ran up a bill all night."

# Goal + tools + bounded loop. The system prompt is the agent's identity and
# instructions; the loop executes tool calls and feeds results back until the
# model answers. The step cap is the safety rail — an agent without one is a way
# to spend money in an infinite loop.
GOAL = ("You are a research agent. Use `lookup` to fetch physical constants and "
        "`calculate` to do arithmetic. Never do math in your head — use the tool. "
        "When you have the answer, state it plainly with units.")


def agent(task: str, max_steps: int = 8) -> str:
    input_list = [{"role": "user", "content": task}]
    response = None
    for _ in range(max_steps):
        response = client.responses.create(model=_MODEL, instructions=GOAL, input=input_list, tools=TOOLS)
        calls = [item for item in response.output if item.type == "function_call"]
        if not calls:
            break
        input_list += response.output
        for call in calls:
            result = TOOLS_IMPL[call.name](**json.loads(call.arguments))
            print(f"  tool: {call.name}({call.arguments}) -> {result}")
            input_list.append({"type": "function_call_output", "call_id": call.call_id, "output": json.dumps(result)})
    return response.output_text if response else ""

The goal string does more work than its size lets on. "Never do math in your head — use the tool" is in there because models will compute wrong with total confidence; force the tool and the number becomes something you can trust. Most of agent design turns out to be instruction design — tell the agent what it's for, which tools to prefer, and when to stop.

Gemini

The abstraction doesn't care which provider you're on — goal, tools, loop — so the Gemini version only changes the tool-calling plumbing. That's the payoff of understanding the pattern instead of memorizing one SDK: you port it in an afternoon.

def agent(task: str, max_steps: int = 8) -> str:
    contents = [types.Content(role="user", parts=[types.Part(text=task)])]
    response = None
    for _ in range(max_steps):
        response = client.models.generate_content(model=_MODEL, contents=contents, config=_CONFIG)
        if not response.function_calls:
            break
        contents.append(response.candidates[0].content)
        parts = []
        for fc in response.function_calls:
            result = TOOLS_IMPL[fc.name](**dict(fc.args))
            print(f"  tool: {fc.name}({dict(fc.args)}) -> {result}")
            parts.append(types.Part.from_function_response(name=fc.name, response=result))
        contents.append(types.Content(role="user", parts=parts))
    return (response.text or "") if response else ""

Two failure modes to watch for once you build real agents. The first is the tool-call loop — an agent that keeps calling the same tool with the same arguments is stuck, and the step cap is what saves you, but go find out why it stuck (usually a tool handed back something the model couldn't use). The second is the silent wrong tool — every so often an agent picks a plausible tool that's the wrong one, which is why you log every call while you're developing. The loop is what makes an agent do anything at all. The log is what lets you fix it when it does the wrong thing.

Put it to work

Three docker-compose apps under code/showcase/<slug>/, each one the same loop wearing a different toolset and goal. Same drill as always: bash bootstrap-secrets.sh, docker compose up --build, http://localhost:3000.

Showcase 1 — Calc agent

A calculator and a unit converter, chained together to solve word problems. This one shows the pattern at its cleanest — the model plans the sequence, the tools supply the exact numbers, and the agent narrates what came back.

Showcase 2 — KB agent

One search tool over a product knowledge base — RAG, except the agent runs it. Week 9 hard-wired retrieve-then-generate; here the model decides when to search and what for, sometimes more than once before it answers. Same grounding, with the model in charge of it.

Showcase 3 — Planner agent

An agent with memory. It keeps a task list, breaks a goal into steps with add_step, works through them with mark_done, and reports back. That list is real per-request state the tools mutate — the seed of everything fancier agents do with scratchpads and working memory.

All three are goal plus tools plus loop, arranged three ways. Once that clicks, you can build an agent for anything you've got tools for.

Run it

The README in this folder lists the Python version, the install line, the two environment variables, and the exact commands. Keys come from the untracked .env at the course root. The basic scripts print every tool call, so you get to watch the agent think.

Takeaways

An agent is a goal, a set of tools, and a bounded loop. Write those three by hand once and every framework afterward reads as convenience layered on a pattern you already own. The loop isn't what makes one trustworthy, though. The discipline around it is: tools that are safe by construction (whitelist the operators, never eval), a hard step cap so a confused agent fails cheap instead of expensive, instructions that push the model onto its tools instead of its own guesses, and a log of every call so you can see what it actually did. Memory is nothing exotic — it's state your tools read and write. Next week we let agents talk to each other: multi-agent orchestration.