DL S075 · Agents and Tool Use
Wire your LLM to Python + web search and watch it plan multi-step tasks. From ReAct to function calling to failure modes. Part of the 'Deep Learning & LLMs From Scratch' 80-session self-study series.
🎯 Build a tool-using agent from scratch — no LangChain, no CrewAI. Understand what the loop looks like, why it hallucinates tool calls, and how ReAct / function-calling actually work under the hood.
Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 75 / 80 · Module M12 · ~2 hours
The story we're starting with
Somewhere in 2022, a paper came out called ReAct — "Reasoning + Acting" — with an idea so embarrassingly simple that people spent a year not believing it worked. The idea: give the LLM a scratchpad, teach it to write down its next intended action as text, then when it writes the special string Action: calculator[2+2], pause generation, actually run the calculator, paste Observation: 4 back into its context, and let it keep generating. That's an agent. That's the whole thing. There is no separate "planning module", no reinforcement learning, no additional network. It's the same next-token predictor from S040 with a for-loop wrapped around it that watches for tool-call tokens.
The reason it works is a fact about pretrained LLMs that took the field a while to internalize: the LLM has already read millions of examples of humans thinking out loud and then acting. Blog posts, StackOverflow answers, tutorials, transcripts. The instinct "think, then act, then observe, then think again" is already in the weights. You don't need to teach it. You just need to (a) give it a syntax to signal "I want to act now", (b) intercept that signal and actually perform the action, and (c) hand back the result. The loop that wraps this up is under 60 lines of Python.
Today we build that loop from scratch, wire it to two tools (a Python REPL and a web-search stub), watch it succeed and fail on a few tasks, and then look at how the same idea got dressed up as "function calling" in the OpenAI API — same trick, different clothes.
- Write an agent loop in under 60 lines of Python: prompt → LLM → parse tool call → run tool → append observation → repeat.
- Explain the difference between ReAct-style text scratchpads and JSON function-calling APIs, and why both are equivalent under the hood.
- Predict the three most common agent failure modes (hallucinated tools, infinite loops, tool-output flooding) and describe one mitigation for each.
- Design a system prompt that reliably gets a base LLM to emit tool calls in a parseable format.
- Reason about when a task actually needs an agent versus when a single well-designed prompt is enough (and it usually is).
Prerequisites
- S040 · nanoGPT rebuild. The agent loop is a wrapper around
model.generate(). You should know what that is. - S063 · Instruction fine-tuning. Function-calling models are instruction-tuned with a specific data mix; that recipe assumes you know what SFT looks like.
- S066 · Prompt engineering fundamentals. The system prompt is 80% of an agent's quality.
- Basic subprocess / eval familiarity in Python — we'll run untrusted-ish code and need to think about sandboxing.
1 · What an agent actually is
Strip all the marketing off "AI agent" and you're left with three things:
- A prompt describing available tools and asking the model to solve a task.
- A generation loop that stops when the model emits a tool-call marker.
- A dispatcher that runs the tool, formats the result, and appends it back to the context.
There is nothing else. No separate planner, no memory subsystem, no RL. Every "agent framework" you'll ever see (LangChain, AutoGen, CrewAI, LlamaIndex agents) is a wrapper around these three things. Sometimes a bloated wrapper.
1.1 · The ReAct prompt format
The original ReAct paper used this scratchpad format:
You have access to the following tools:
- calculator(expression: str) -> str: Evaluates a Python arithmetic expression.
- search(query: str) -> str: Returns the top 3 search results for a query.
Use this format:
Thought: <your reasoning>
Action: <tool_name>[<arg>]
Observation: <will be filled in by the environment>
... (repeat Thought/Action/Observation as needed)
Thought: I know the final answer.
Final Answer: <answer>
Question: What is the population of France divided by the population of Germany?The model then generates:
Thought: I need to look up both populations.
Action: search[population of France]Your code sees Action: search[population of France], stops generation, runs the search, and injects:
Observation: France's population is approximately 68 million (2024).Then resumes generation. The model continues:
Thought: Now I need Germany.
Action: search[population of Germany]And so on until it emits Final Answer:.
2 · Building it from scratch
Let's write it. We'll assume an OpenAI-compatible chat endpoint (works with your local capstone model via vLLM or with a real API).
2.1 · The tool registry
import re, subprocess
def tool_calculator(expr: str) -> str:
try:
# NOT for production. Use ast.literal_eval or a real sandbox.
result = eval(expr, {"__builtins__": {}}, {})
return str(result)
except Exception as e:
return f"Error: {e}"
def tool_search(query: str) -> str:
# Stub. In production this would hit SerpAPI, Brave, Tavily, etc.
fake_db = {
"population of france": "France's population is approximately 68 million (2024).",
"population of germany": "Germany's population is approximately 84 million (2024).",
}
return fake_db.get(query.lower(), f"No results for '{query}'.")
TOOLS = {"calculator": tool_calculator, "search": tool_search}Two tools. That's enough to see the loop work.
2.2 · The parser
The model will emit lines like Action: search[population of France]. Parse it:
ACTION_RE = re.compile(r"Action:\s*(\w+)\[(.+?)\]", re.DOTALL)
def parse_action(text: str):
m = ACTION_RE.search(text)
if not m:
return None, None
return m.group(1), m.group(2)Robust enough for a demo. Production agents use JSON parsing (see §4).
Wire the code above to your favorite chat endpoint (vLLM on localhost:8000, openai SDK, or a real API). Ask it: "What is the population of France divided by the population of Germany, rounded to two decimals?" It should call search twice and calculator once, in that order. Now break it on purpose: change the search stub to return "unknown" for both. Watch the agent either loop, invent numbers, or admit defeat. Which one it does tells you a lot about the base model's calibration.
2.3 · The loop
def run_agent(question: str, llm_call, max_steps: int = 8) -> str:
prompt = SYSTEM_PROMPT + f"\nQuestion: {question}\n"
trace = ""
for step in range(max_steps):
# Ask model to generate until it emits an Action or Final Answer.
completion = llm_call(prompt + trace, stop=["Observation:", "Final Answer:"])
trace += completion
if "Final Answer:" in completion:
# Get the rest after the marker.
answer_completion = llm_call(prompt + trace, stop=None)
trace += answer_completion
return trace.split("Final Answer:")[-1].strip()
tool_name, arg = parse_action(completion)
if tool_name is None:
trace += "\nObservation: [parse error — no action found]\n"
continue
if tool_name not in TOOLS:
trace += f"\nObservation: [unknown tool '{tool_name}']\n"
continue
obs = TOOLS[tool_name](arg)
trace += f"\nObservation: {obs}\n"
return "[max steps exceeded]\n" + traceThat's the whole agent. ~30 lines including whitespace. Every framework you've ever heard of is a variation on this file.
2.4 · Trace of one run
For "population of France divided by Germany":
Thought: I need both populations.
Action: search[population of France]
Observation: France's population is approximately 68 million (2024).
Thought: Now Germany.
Action: search[population of Germany]
Observation: Germany's population is approximately 84 million (2024).
Thought: Now the division.
Action: calculator[68/84]
Observation: 0.8095238095238095
Final Answer: About 0.81 (France has roughly 81% of Germany's population).Four LLM calls, two searches, one calculator hit. Total latency: whatever your model's generation speed is, dominated by the LLM. The "reasoning" is real but happens entirely in the text stream. There is no hidden state.
3 · Function calling — the same trick, dressed up
OpenAI shipped "function calling" in June 2023. It's ReAct with two differences:
- JSON schema up front. You declare tools as JSON schemas, so the model knows the exact argument shape.
- A special token boundary. Instead of parsing free text, the model outputs a structured object; the API decodes it for you.
Under the hood, function-calling models are just SFT'd on a data mix that includes examples like:
[user] What's the weather in Paris?
[assistant] {"tool_call": {"name": "get_weather", "args": {"city": "Paris"}}}
[tool result] {"temp": 18, "condition": "cloudy"}
[assistant] It's 18°C and cloudy in Paris right now.That's it. The "function calling capability" is 100k SFT examples of this shape. There's no new architecture. If you fine-tuned your capstone model on the same shape of data, it would function-call too. (In fact, the open-source functionary and Mistral's Tool models did exactly that.)
3.1 · Why the JSON form is better in production
- Fewer parse errors. The model tends to emit valid JSON when SFT'd for it; free text
Action: foo[bar]breaks on nested brackets or commas. - Easier arg validation. You have a schema, so you can reject malformed calls at the framework layer instead of relying on the model.
- Streaming-friendly. The API can tell you "I'm now emitting a tool call" as soon as the first JSON token appears, without waiting for a specific string.
3.2 · Why the text form is fine for hobby projects
Everything above matters at scale. For your capstone: a regex over free text is 30 lines, works on any model, and requires zero SFT. Ship the simple thing first.
4 · Where agents fall over
I gave the model tools 'calculator' and 'search'. It emitted Action: python[import numpy; np.log(2)]. There is no python tool. Model just… made one up because it had seen thousands of ReAct traces on the internet that had a Python tool. Fix: explicit unknown-tool observation ('unknown tool python — available tools are: calculator, search') plus a system prompt example showing the model recovering from that error. Model got the message on the next step.
Asked an agent to 'find a good restaurant near me.' It ran search → search → search → search, refining the query each time, never emitting Final Answer. It genuinely thought each iteration was making progress. Fix: (a) hard max_steps cap (I use 8), (b) inject a nudge at step k-2: 'You have 2 steps left. Emit Final Answer with your best current guess.'
Search tool returned a 10k-token web page. Next LLM call blew past context length. Fix: truncate tool outputs to ~500 tokens with a note '[output truncated — re-query with more specific terms for details]'. Model learns to be specific. Or: add a summarization sub-tool the model can call on long outputs.
Naive tool_calculator uses eval(). Model, being helpful, tried to eval import('os').system('rm -rf /'). It failed because I'd blocked builtins, but it was a wake-up call. Real answer: use RestrictedPython, or better, run tools in a subprocess with seccomp / a Docker sandbox. Do not eval() model output on any machine you care about.
5 · When you don't need an agent
The industry over-uses agents. If your task is:
- Single-turn Q&A: don't. Just prompt the model directly.
- Retrieval-then-answer: don't. Just RAG (see S076 tomorrow).
- Structured extraction: don't. Just use JSON-mode / grammar-constrained decoding.
You need an agent when the task is truly multi-step with data-dependent branching — e.g., "book the cheapest flight for a trip that satisfies these 5 constraints, checking calendar and budget as you go." That's an agent. "Summarize this document" is not.
6 · Diagram — the agent loop
7 · The 2024–2025 agent landscape — what changed
Between Yao et al.'s original ReAct paper (Oct 2022, arXiv:2210.03629) and today, agents went from research demo to a $B+ market. The vocabulary you need:
7.1 · Reflexion (Shinn et al., 2023, arXiv:2303.11366)
Standard ReAct has no memory across attempts — fail a task, retry from scratch, fail the same way. Reflexion adds a verbal self-critique step: after failure, the agent generates a natural-language reflection ("I searched for X but the tool returned Y — next time I should try Z first") and stores it. On the next attempt, the reflection is prepended to the prompt. On HumanEval coding, Reflexion pushed GPT-4 from ~67% to ~91% pass@1 without any weight updates. That's a 24-point jump from adding a paragraph of prompt context. Storage is the cheapest form of intelligence.
7.2 · Voyager (Wang et al., 2023, arXiv:2305.16291)
Voyager — a Minecraft-playing GPT-4 agent — introduced skill library growth: successful action sequences get compiled into named Python functions and added to a growing library the agent can call in future episodes. After ~160 episodes, Voyager had bootstrapped a library of ~200 skills (mine wood → craft table → craft pickaxe → mine stone → …) and unlocked diamond-tier items 3× faster than baseline ReAct. Lesson: the tool set should grow with experience, not be fixed at design time.
7.3 · Native tool-calling and structured decoding (2024)
OpenAI's tools=[...] parameter (Nov 2023) and Anthropic's tool-use API (Apr 2024) moved tool declarations out of the prompt into a first-class API field. Under the hood the model is still SFT'd on tool-formatted data — but the API layer handles the JSON grammar and parses arguments for you. Combined with constrained decoding libraries like Outlines (arXiv:2307.09702) and XGrammar, you can now guarantee the model outputs a valid tool call — no more "model hallucinated a trailing comma and my JSON parser died at 2am."
7.4 · Computer-use agents (late 2024 → 2025)
The biggest shift: agents that don't call APIs, they operate a mouse and keyboard on a screenshot. Anthropic's Computer Use (Claude 3.5 Sonnet, Oct 2024) and OpenAI's Operator / CUA (Jan 2025) take a screenshot, output pixel coordinates + click/type actions, take another screenshot, repeat. The tool interface is (screenshot) → (x, y, action). Same ReAct loop you built; the observation is a PNG instead of a string.
Why this matters: most enterprise software has no API. SAP forms, hospital EHR systems, that ancient JIRA instance. A computer-use agent unlocks all of it. Failure modes are new though: the model can't reliably click a small button in a busy UI, and hallucinated clicks are terrifying when the button says "Delete customer." Anthropic's release explicitly recommends running inside a sandboxed VM with human confirmation on any destructive action.
7.5 · Multi-agent frameworks (AutoGen, CrewAI, LangGraph)
2024's other trend: instead of one agent with all tools, spawn multiple specialized agents that message each other. AutoGen (Wu et al., Microsoft, arXiv:2308.08155), CrewAI, and LangGraph all provide the plumbing. The empirical evidence is mixed — Salesforce's Agent-as-a-Judge (arXiv:2410.10934) found multi-agent setups often increase error rates by 10–20% because each hop adds parsing noise. Use multi-agent when roles are genuinely different (planner vs coder vs reviewer), not just for the aesthetics.
7.6 · Evaluation — SWE-bench, GAIA, WebArena
"My agent works" needs a benchmark. The 2024–2025 standards:
- SWE-bench Verified (Jimenez et al., arXiv:2310.06770 + OpenAI's human-audited subset): fix real GitHub issues. Claude Sonnet 4 and GPT-5 sit around 65–75% pass rate as of mid-2025 — up from 2% in early 2024.
- GAIA (Mialon et al., arXiv:2311.12983): general-assistant multi-step reasoning with tools. Humans ~92%, top agents ~65%.
- WebArena / VisualWebArena (Zhou et al., arXiv:2307.13854): actual browser tasks in sandboxed sites. Humans ~78%, top agents ~35% — still wide open.
Run your agent on at least one of these before shipping. "Vibes-based" evaluation of agents is how people burn six figures on the wrong stack.
7.7 · Further reading
- ReAct: https://arxiv.org/abs/2210.03629 · Yao et al. 2022
- Reflexion: https://arxiv.org/abs/2303.11366 · Shinn et al. 2023
- Voyager: https://arxiv.org/abs/2305.16291 · Wang et al. 2023
- Toolformer: https://arxiv.org/abs/2302.04761 · Schick et al. 2023
- AutoGen: https://arxiv.org/abs/2308.08155 · Wu et al. 2023
- SWE-bench: https://arxiv.org/abs/2310.06770 · https://www.swebench.com/
- GAIA: https://arxiv.org/abs/2311.12983
- Anthropic Computer Use: https://www.anthropic.com/news/3-5-models-and-computer-use
- OpenAI Operator/CUA: https://openai.com/index/introducing-operator/
- Outlines (constrained decoding): https://arxiv.org/abs/2307.09702
8 · Try it yourself
Build the smallest useful agent this week:
- Pick a real annoyance in your life that involves 3+ steps and a data-dependent branch. (Mine: "find papers cited by paper X that were also cited by paper Y, filter to last 12 months.")
- Sketch the 2–4 tools it needs (
search_arxiv,get_citations,intersect,filter_by_date). - Implement each tool as a plain Python function (~15 lines each).
- Wire the 30-line
run_agentloop from §2 with Claude or GPT native tool-calling. - Run it on 10 real instances. Log failures. Add one Reflexion-style retry.
Budget: one evening. Reward: you'll never again mistake "I chained LangChain runnables" for "I built an agent."
9 · Recap
An agent is a while loop around an LLM: generate → parse a tool call → run it → append the observation → repeat until the model says "done." That's it. ReAct proved this works with zero fine-tuning because "think then act" transcripts are all over the pretraining corpus. Every 2024–2025 advance (Reflexion, Voyager, computer-use, native tool-calling) is a variation on the observation channel — richer memory, growing skills, screenshots instead of strings. The failure modes are the same as any long-context system: hallucinated tools, infinite loops, context flooding. The mitigations are boring engineering: validate, cap, truncate, sandbox.
"An agent is a smarter model. Give a capable LLM some tools and a goal, and it will figure out the steps — the intelligence is in the model, the loop is just plumbing."
The model contributes one thing per iteration: a single next-action proposal conditioned on the transcript so far. Everything that makes an agent work or fail lives outside the model — how tool results are formatted, what fraction of the context they consume, whether errors are surfaced as recoverable signals or as walls, when the loop terminates, and what happens on repetition. Two agents on the identical model with different loops behave nothing alike, and the gap is usually larger than the gap between models.
This belief is sticky because it is nearly true for single-turn tasks, which is where you formed your intuition about model quality. In one turn, a better model really does produce a better answer, and the surrounding code really is plumbing. What changes with a loop is error dynamics: a single-turn error is one bad answer, but an error at step three is an input to steps four through twenty. Errors compound along a trajectory, and compounding is a property of the loop, not of the model. So you can be entirely right that model quality dominates single-turn performance and entirely wrong about agents, because you have moved to a regime where per-step accuracy matters less than whether the system can notice and recover.
Hold the model fixed and vary only the loop:
# same model, same tools, same task set - three loops
variants = {
'raw' : dict(on_error='append_traceback', max_steps=20, dedup=False),
'terse' : dict(on_error='append_short_hint', max_steps=20, dedup=False),
'guarded' : dict(on_error='append_short_hint', max_steps=8, dedup=True),
}
for name, cfg in variants.items():
r = run_suite(tasks, model=MODEL, **cfg)
print(name, 'solved', r.solved, 'avg_steps', r.avg_steps,
'avg_tokens', r.avg_tokens, 'loops_detected', r.loops)Same model in all three rows. The spread between them is the part you were calling plumbing.
Why do agents fail more as you give them more steps to work with, when more steps should mean more chances to get it right?
- 1Each iteration conditions on the entire transcript: the goal, every prior action, and every prior observation.forced by · the model is stateless, so the transcript is the only memory the system has
- 2A mistaken action does not merely fail — it writes itself into that transcript and becomes part of the context for every subsequent decision.forced by · the model cannot distinguish "this happened and was wrong" from "this happened" unless something explicitly marks it
- 3So errors are not independent across steps. The probability of a good step declines as the transcript accumulates failed attempts, because the model is now imitating a trajectory that has been going badly.forced by · next-token prediction continues the pattern it is shown, and a transcript of flailing is a pattern of flailing
- 4Meanwhile the transcript grows monotonically — tool outputs are often far larger than the model's own turns — pushing the original goal further from the generation point and eventually out of context entirely.forced by · observations are unbounded in size while the model's actions are short, so the ratio shifts toward observations over time
- 5Hence more steps means both more accumulated error to imitate and a more diluted goal, and the two reinforce each other.forced by · a diluted goal produces worse actions, which add more failure to the transcript, which dilutes further
Therefore step budget is not a resource that helps monotonically — past a point it is the mechanism of failure, because an agent that has not solved the task by then is usually in a state where continuing makes things worse.
And note the prediction. First: agents should fail in loops specifically — repeating a failed action verbatim — because a transcript containing that action is the strongest available evidence of what to do next. Detect exact repeats in your traces and expect them to cluster right before failure. Second: truncating or summarising the transcript should sometimes improve success rather than merely save tokens, which is a strange prediction if context is purely informative and an obvious one if accumulated failure is contagious. Test it by compacting old observations and keeping the goal pinned at both the start and the end. Third: how you format an error should measurably change the outcome — a terse actionable hint should outperform a full stack trace not because it is shorter but because it reads as a recoverable state rather than as a wall.
An agent is a while loop where the model chooses the next statement and the environment supplies the result. That is all. The loop body is: append the observation, ask for one action, execute it. The intelligence is one-step-deep; the appearance of planning comes from the transcript, not from any plan the model holds.
Which means every agent bug is an ordinary loop bug wearing a costume. Non-termination, unbounded memory growth, no error handling, no idempotence on retry — you have debugged all of these before, in code, and the same fixes apply: a step cap, a compaction policy, structured error returns, and a repeat detector.
- The transcript is the state. Anything not in it does not exist; anything in it influences every future step, including the mistakes.
- Errors compound along a trajectory. Per-step accuracy matters far less than recoverability.
- Tool output size is a first-class design decision. An unbounded tool result is an unbounded context bug.
- Always bound the loop and always detect repeats. An agent without a step cap is a program without a termination condition.
- If the task decomposes into a known sequence, write the sequence. An agent is for when the branching genuinely cannot be enumerated, and it costs an order of magnitude more to run and to debug.
Fire this the moment you see: an agent repeating the same failing call · cost per task that varies by an order of magnitude across runs · a tool that returns an entire file or an entire API response · an agent that works on demos and fails on real inputs · "let the agent figure it out" for a task with three known steps · no maximum step count in the loop · errors surfaced to the model as raw stack traces.
You have a multi-step task. Do you write a fixed pipeline, an agent loop, or a constrained hybrid?
Default to the pipeline and justify the agent, not the other way round. The honest test is whether you can draw the flowchart: if you can, an agent is a more expensive, slower, less debuggable implementation of that flowchart. Most production "agents" that work well turn out on inspection to be pipelines with one or two genuinely open steps inside them, and that is the shape worth aiming for directly.
When you do run a loop, the two controls that matter most are cheap and frequently omitted: a hard step cap, so the worst case is bounded rather than discovered on your bill, and repeat detection, because the same failing action twice is the highest-precision signal that the agent is stuck and no further step will help. Add structured error returns as the third — a short hint the model can act on beats a stack trace it will imitate. Those three cost an afternoon and eliminate most of the failure modes that make agents feel unpredictable.
🧠 Retention scaffold
One-line summary (write it in your own words): _______________________________
Spaced review: re-read §2 (the 30-line loop) + §4 (failure modes) in 24 hours. Revisit alongside a SWE-bench trajectory on day 7.
Next session (S076): an agent that can search is only as good as its retriever. We build RAG from scratch — chunker, index, reranker, generator — with no LangChain, so you know exactly what the frameworks are hiding.
Sticky note (keep on your desk): An agent is a while loop. Everything else — memory, tools, planners, multi-agent orchestration — is decoration on that loop.
Previous: ← DL S074 · Next: DL S076 → RAG from Scratch