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.