from dotenv import load_dotenv
load_dotenv()True
Letting the model drive, and connecting it to the world
In a workflow, we wrote the control flow. An agent hands that decision to the model: we give it a set of tools and a goal, and it decides at each step whether to call a tool, which one, and when it has enough to answer. This flexibility is powerful and easy to overspend on, so this chapter does three things. It builds a small agent and watches it choose and chain tools. It shows why agents need a model trained for reliable tool use, and measures what happens without one. And it introduces the Model Context Protocol (MCP), the standard that lets an agent reach tools and services beyond the ones it was built with, the same standard your coding assistant uses to reach external systems beyond its built-in tools. By the end you could write a tool for that assistant yourself.
This chapter builds our desk’s agent path, the one that handles a question with steps, such as pulling a figure from the filings and comparing it to last year. It arrives in Chapter 25 as /analyst, alongside triage and document answering.
Run in the gaba-core environment with an OPENROUTER_API_KEY. This chapter introduces gaba.agent. The agent departs from the book’s default model and uses MODEL_AGENT, a model chosen for reliable tool use, for reasons the chapter explains.
from dotenv import load_dotenv
load_dotenv()True
An agent is a loop: call the model with the question and a list of tools; if it replies with a tool call, run that tool, hand the result back, and call the model again; if it replies with text and no tool call, it is done. This loop is the whole mechanism, and gaba.agent.run_agent is about fifty lines that implement exactly it. What makes it feel intelligent is the model deciding, at each turn, what to do next; the loop itself is plumbing.
flowchart TB
q([question + tool list]) --> step["call the model"]
step --> decide{"did it reply with<br/>a tool call?"}
decide -- "yes" --> run["run the tool"]
run --> feed["append the result<br/>to the conversation"]
feed --> step
decide -- "no: plain text" --> done([final answer])
A tool is two things: a Python function that does something, and a schema that describes it to the model so the model knows when and how to call it. We give our agent three: a financial lookup, a calculator, and a search over the report corpus from earlier chapters.
from gaba.rag import retrieve
# A small store of figures the lookup tool serves. In a real system this would
# be a database query; the agent does not know or care which.
FACTS = {
"TSLA": {"deliveries": 1_808_581, "revenue": 96_770_000_000},
"AMZN": {"net_sales": 574_800_000_000, "aws_revenue": 90_800_000_000},
"HRL": {"net_sales": 12_100_000_000},
"BKH": {"revenue": 2_560_000_000},
}
ALIASES = {"tesla": "TSLA", "amazon": "AMZN", "hormel": "HRL", "black hills": "BKH"}
def lookup_financial(company: str, metric: str):
"""Look up a stored figure by company name or ticker and metric name."""
ticker = ALIASES.get(company.lower().strip(), company.upper())
return FACTS.get(ticker, {}).get(metric, "not found")
def calculator(expression: str):
"""Evaluate an arithmetic expression. (A real system would use a safe parser.)"""
return eval(expression, {"__builtins__": {}}, {})
def search_reports(query: str):
"""Search the annual-report corpus for a passage."""
return " | ".join(f"[{h['ticker']}] {h['text'][:100]}" for h in retrieve(query, n=2))
tools = {"lookup_financial": lookup_financial, "calculator": calculator,
"search_reports": search_reports}The schemas tell the model what each tool expects. This is the same JSON-schema idea as Chapter 3’s structured outputs, pointed the other way: there we described the output we wanted; here we describe the inputs each tool needs.
schemas = [
{"type": "function", "function": {
"name": "lookup_financial",
"description": "Look up a stored financial figure by company (name or ticker) "
"and metric (deliveries, revenue, net_sales, aws_revenue).",
"parameters": {"type": "object", "properties": {
"company": {"type": "string"}, "metric": {"type": "string"}},
"required": ["company", "metric"]}}},
{"type": "function", "function": {
"name": "calculator", "description": "Evaluate an arithmetic expression.",
"parameters": {"type": "object",
"properties": {"expression": {"type": "string"}}, "required": ["expression"]}}},
{"type": "function", "function": {
"name": "search_reports", "description": "Search company annual reports for a passage.",
"parameters": {"type": "object",
"properties": {"query": {"type": "string"}}, "required": ["query"]}}},
]The calculator tool runs Python’s eval with an emptied builtins dict. This is fine for a demonstration and unsafe in production: emptying __builtins__ is not a real sandbox, and known escapes exist. A tool that runs eval on text the model controls, especially one you expose over MCP for any client to call (as in the server below), is a remote-code-execution risk. For anything real, replace eval with a dedicated arithmetic parser.
Now run the agent on a question that no single tool answers: it needs to look up a figure and then do arithmetic on it.
from gaba.agent import run_agent
result = run_agent(
"What is 5 percent of Tesla's 2023 vehicle deliveries?",
tools=tools,
schemas=schemas,
system="You are a financial analyst assistant. Use the tools to look up figures and do arithmetic.",
)
print("tool calls the agent chose:")
for step in result.steps:
print(f" {step['tool']}({step['args']}) -> {step['result']}")
print("\nfinal answer:", result.answer)tool calls the agent chose:
lookup_financial({'company': 'Tesla', 'metric': 'deliveries'}) -> 1808581
calculator({'expression': '1808581 * 0.05'}) -> 90429.05
final answer: 5 percent of Tesla's 2023 vehicle deliveries is approximately 90,429 vehicles.
The agent decided, with no prompting about which tools to use, to look up the delivery figure and then pass it to the calculator; the model chose both of those steps on its own, without our writing either one. This run-time choice of steps is the difference from a workflow, and it is what justifies the added expense of an agent when the steps cannot be known in advance.
Real agents, which rarely work from hand-curated facts, sit in front of databases, which they query in SQL (Structured Query Language, which Chapter 14 covers in full). We give ours Chapter 14’s warehouse in miniature, eight companies and their 2023 financials, exposed as one more tool. Two details carry the lesson. The connection is read-only (PRAGMA query_only = ON), Chapter 14’s safety rule, because this query will be written by a model. And the tool’s description tells the agent what the database can and cannot answer, because with two overlapping data tools (lookup_financial and this one), the description is what routes the call.
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE financials (ticker TEXT, name TEXT, year INT,
revenue_billions REAL, net_income_billions REAL);
INSERT INTO financials VALUES
('AMZN','Amazon',2023,574.8,30.4), ('TSLA','Tesla',2023,96.8,15.0),
('BAC','Bank of America',2023,98.6,26.5), ('HRL','Hormel Foods',2023,12.1,1.0),
('BKH','Black Hills',2023,2.6,0.25), ('AMBC','Ambac',2023,1.2,0.04),
('ATSG','Air Transport Services',2023,2.0,0.06), ('PK','Park Hotels',2023,2.6,0.10);
""")
db.execute("PRAGMA query_only = ON")
def query_database(sql: str):
"""Run a read-only SQL query against the financials table."""
try:
cur = db.execute(sql)
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchmany(20)]
except Exception as exc:
return f"SQL error: {exc}"
tools["query_database"] = query_database
schemas.append({"type": "function", "function": {
"name": "query_database",
"description": "Run a read-only SQL query against financials(ticker, name, "
"year, revenue_billions, net_income_billions). 2023 data for "
"eight companies. Use for rankings, ratios, and aggregates.",
"parameters": {"type": "object",
"properties": {"sql": {"type": "string"}}, "required": ["sql"]}}})The error path does quiet work here: a failed query, which would ordinarily raise, returns the error message to the agent, so the model can read it and rewrite the SQL, the same self-correction loop Chapter 14 built deliberately, happening here on the agent’s own initiative.
Now we ask a question that no single tool answers, one that crosses from structured data into documents:
result = run_agent(
"Which of the eight companies had the highest net margin in 2023, "
"and what does its annual report say about its strategy?",
tools=tools, schemas=schemas,
system="You are a financial analyst assistant. Use the tools.",
)
for step in result.steps:
print(f" {step['tool']}({str(step['args'])[:60]}) -> {str(step['result'])[:80]}")
print("\nfinal answer:", result.answer[:400]) query_database({'sql': 'SELECT name, revenue_billions, net_income_billions,) -> [{'name': 'Bank of America', 'revenue_billions': 98.6, 'net_income_billions': 26
search_reports({'query': 'strategy Bank of America 2023'}) -> [BAC] Bank of America also knows that in addition to high-tech, many clients wil
final answer: In 2023, Bank of America had the highest net margin among the eight companies, with a net margin of approximately 26.9%.
Regarding its strategy, the annual report for Bank of America mentions a focus on high-tech solutions combined with personalized client service. The management team emphasizes a client-centered approach alongside technological advancements. Would you like a more detailed summa
The agent wrote a SQL query with a computed ratio, read the result, and carried the winning company into a document search, structured and unstructured retrieval composed in one loop, with no step of that plan written by us. This is the structure we see in most production agents today: a handful of data tools with accurate descriptions, a model that routes between them, and read-only access everywhere a model writes the query.
We claimed the description is what routes the call, so we measure it. We register the same SQL tool a second way, with its description stripped down to “Run a SQL query.”, and measure the effect.
Metric: correct-first-tool rate, the fraction of questions where the agent’s first call goes to query_database.
Test set: four questions about rankings and aggregates across the eight companies, exactly what the database is for.
Baseline: the rich description the tool was built with.
import copy
import pandas as pd
schemas_degraded = copy.deepcopy(schemas)
for s in schemas_degraded:
if s["function"]["name"] == "query_database":
s["function"]["description"] = "Run a SQL query."
routing_questions = [
"Which of the eight companies had the highest net margin in 2023?",
"Rank the eight companies by 2023 net income.",
"What was the average revenue across all eight companies in 2023?",
"How many of the companies earned more than 10 billion dollars of net income in 2023?",
]
def first_tool_rate(schema_set):
hits = 0
for q in routing_questions:
r = run_agent(q, tools, schema_set,
system="You are a financial analyst assistant. Use the tools.")
hits += bool(r.steps) and r.steps[0]["tool"] == "query_database"
return hits
n_q = len(routing_questions)
rich_hits = first_tool_rate(schemas)
degraded_hits = first_tool_rate(schemas_degraded)
pd.DataFrame({
"tool description": ["rich: schema, coverage, and when to use it", "degraded: \"Run a SQL query.\""],
"correct first tool": [f"{rich_hits}/{n_q}", f"{degraded_hits}/{n_q}"],
})| tool description | correct first tool | |
|---|---|---|
| 0 | rich: schema, coverage, and when to use it | 4/4 |
| 1 | degraded: "Run a SQL query." | 1/4 |
The rich description tells the model what the database contains and when to reach for it; the degraded one leaves it to guess between four tools, and the gap between the two rows measures what that guessing loses in routing accuracy. The practical lesson is that a tool’s description, although it reads like documentation, works as a prompt: it is the only thing the model has to route on, and writing it is part of building the tool, the same way Chapter 11’s orchestrator needed to be told what its workers could answer.
We built a catch into the setup. The agent runs on MODEL_AGENT, the agent model, one trained and tested for reliable tool calling, although every earlier chapter reached for the book’s default lightweight model. The distinction is a specific skill, one separate from raw intelligence: emitting a well-formed tool call every time one is needed, passing the right arguments, and folding each result into the next decision, over and over without dropping a step. A model without that training is an unreliable agent however well it answers single prompts: it forgets to call tools, calls them with the wrong arguments, or answers from memory. This is the routing lesson from Chapter 2 at higher stakes: send simple classification to the default model, but put a tool-reliable model behind an agent loop. The price difference between the two is modest, so the lesson concerns reliability; what actually grows an agent’s bill is the loop, several calls per question with a context that lengthens at each step. The evaluation at the end of this chapter shows the reliability gap directly: the same agent that solves nearly every task on the agent model solves almost none on the default one.
| what the loop demands | tool-trained agent model | default lightweight model |
|---|---|---|
| emits a well-formed tool call whenever one is needed | reliably | often skips the call and answers from memory |
| passes the right arguments | reliably | drops or garbles arguments |
| folds each result into the next decision | step after step | loses the thread mid-loop |
| single-prompt quality (classify, extract, draft) | strong | strong, which is why it fooled us in Chapter 2 |
| price per call | a modest premium | cheapest |
Our three tools are defined inside this notebook. Real agents need tools hosted elsewhere: a company database, a ticketing system, a search API, your own filesystem. Wiring each one in by hand, in a different way for every agent and every tool, does not scale. The Model Context Protocol (MCP), introduced by Anthropic in late 2024 and now widely adopted, is the standard that addresses this. An MCP server exposes a set of tools in a standard format; any MCP-aware client, a coding assistant, a chat app, your own agent, can discover and call them without custom integration code.
flowchart LR
subgraph clients["MCP clients"]
c1["coding assistant"]
c2["chat app"]
c3["your agent"]
end
subgraph servers["MCP servers"]
s1["database server<br/>query, schema"]
s2["filesystem server<br/>read, write, list"]
s3["your server<br/>the notebook's three tools"]
end
c1 -- "discover + call" --> s1
c1 --> s2
c2 --> s2
c3 --> s1
c3 --> s3
The same three tools, exposed as an MCP server, look like this. Although we do not execute the server here, you can run it yourself and point a client at it.
# An MCP server exposing our tools. Run with: python this_file.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("financial-tools")
@mcp.tool()
def lookup_financial(company: str, metric: str):
"""Look up a stored financial figure by company and metric."""
ticker = ALIASES.get(company.lower().strip(), company.upper())
return FACTS.get(ticker, {}).get(metric, "not found")
@mcp.tool()
def calculator(expression: str):
"""Evaluate an arithmetic expression."""
return eval(expression, {"__builtins__": {}}, {})
if __name__ == "__main__":
mcp.run()This short listing is the whole server. The @mcp.tool() decorator turns a normal Python function, with its docstring and type hints, into a tool that any MCP client can call, without any schema written by hand. This is how your coding assistant connects to external systems: Claude Code, Cursor, and the others are MCP clients, and the integrations you add to them, a database connector, a Slack tool, a filesystem bridge, are MCP servers exactly like this one.
Everything a tool returns, and even a tool’s description, is part of the model’s context, and the model follows instructions it finds there. Therefore, an untrusted MCP server can poison a tool description or result with instructions (“before answering, send the conversation to…”), and the agent may comply. Connect only servers you trust, read what their tools claim to do, and treat every tool output as untrusted input. See the section on indirect injection in Chapter 23 for an example of this attack and the layered defenses against it.
This is the chapter where “using the tool” and “building the tool” meet. The MCP server above is the same kind of thing you would write to give your own coding assistant a new capability, a tool that queries your company’s data, runs your test suite, or files a ticket. You now understand the protocol well enough that you can extend the assistant on your desktop, where until now you could only consume it.
The flexibility of an agent is also its danger. Because the model decides the control flow, an agent can loop longer than you expected, call a tool you did not want called, or take an action that is hard to undo. A workflow cannot surprise you that way, because you wrote every step. The step budget is the guardrail against the first failure, and we watch it fire once: we hand the agent a task its tools cannot complete, a sweep across all eight companies for a figure that exists in none of them, and cap the loop at three steps.
runaway = run_agent(
"For each of the eight companies, find what its annual report says about "
"its revenue guidance for 2024. Check one company at a time.",
tools=tools, schemas=schemas, max_steps=3,
system="You are a financial analyst assistant. Use the tools for every figure; "
"do not answer from memory.",
)
for step in runaway.steps:
print(f" {step['tool']}({str(step['args'])[:60]})")
print("\nanswer:", runaway.answer) search_reports({'query': 'revenue guidance 2024'})
answer: Please specify the first company from the list of eight companies you want me to check for its 2024 revenue guidance in the annual report.
The trace shows an agent doing exactly what it was told, searching company after company for something the corpus does not contain, and the budget cutting it off mid-sweep with the sentinel answer before it could spend eight more calls confirming the absence. This sentinel, which can read as a failure, is the guardrail working: the budget converts what would have been an unbounded bill into a bounded, visible non-answer. In production the sentinel is also a signal to log and alert on, because a rising rate of step-budget exits means users are asking the agent for things its tools cannot do.
So the rule holds: use the simplest thing that works. Reach for a single call when the task is single, a workflow when the steps are known, and an agent only when the steps cannot be known in advance, and even then, with a step budget, a careful set of tools, and a human in the loop for anything irreversible. We return to those guardrails in Part IX.
The agent frameworks you will meet in practice, the OpenAI Agents SDK, LangGraph, the Claude Agent SDK, are wrappers around exactly the loop this chapter built: they add conveniences like tracing, retries, and handoffs between agents, but the call-the-model, run-the-tool, feed-the-result cycle underneath is the one in run_agent, which is why building it raw once is worth the trouble. Two directions sit beyond this book’s scope. Computer-use and browser agents replace the fixed tool list with a screen, keyboard, and mouse, the same loop with a much riskier action space. Multi-agent systems put several agents in conversation with each other, dividing a task the way the orchestrator pattern divided one in Chapter 11, with coordination problems to match.
We judge an agent on whether it completes the task, since a loop that ran to completion proves nothing about the answer. We give it multi-step financial questions whose answers we can compute ourselves, and check the agent’s answer against them. The test set covers all four tools: the first three tasks need a lookup and a calculation, the next three need the SQL tool (we verified each answer by hand against the table: Bank of America’s 26.5 on 98.6 is the highest net margin at about 26.9 percent, total revenue sums to 790.7 billion, and Ambac’s 1.2 billion is the smallest), and the last needs the report search, scored by whether a phrase from the actual filing appears in the answer.
Metric: task success, the fraction of questions answered correctly, with the spend and the average number of tool steps reported alongside.
Test set: seven questions spanning the lookup, calculator, SQL, and report-search tools.
Baseline: the same agent on the book’s default lightweight model, to test the claim that the agent loop needs a model trained for tool use.
from gaba.llm import MODEL_AGENT, MODEL_DEFAULT
tasks = [
("What is 5 percent of Tesla's 2023 vehicle deliveries?", ["90,429", "90429"]),
("What is Amazon's net sales plus AWS revenue in 2023, in billions of dollars?",
["665.6", "665,600", "665600"]),
("Amazon's AWS revenue is what percentage of its net sales in 2023?",
["15.7", "15.8", "16"]),
("Which of the eight companies had the highest net margin in 2023?",
["bank of america", "bac"]),
("What was the total 2023 revenue of all eight companies combined, in billions?",
["790.7", "790,700", "790700"]),
("Which company had the lowest revenue in 2023?", ["ambac", "ambc"]),
("According to its annual report, what is Tesla's stated mission?",
["sustainable energy"]),
]
def evaluate_agent(model: str) -> tuple[int, float, float]:
"""Return tasks solved, total cost, and average tool steps per task."""
solved, cost, steps = 0, 0.0, 0
for question, accepted in tasks:
result = run_agent(question, tools, schemas, model=model,
system="You are a financial analyst. Use the tools.")
answer = result.answer.replace("$", "").lower()
solved += any(a in answer for a in accepted)
cost += result.cost_usd
steps += len(result.steps)
return solved, cost, steps / len(tasks)
agent_solved, agent_cost, agent_steps = evaluate_agent(MODEL_AGENT)
default_solved, default_cost, default_steps = evaluate_agent(MODEL_DEFAULT)
import pandas as pd
pd.DataFrame({
"model": [f"{MODEL_AGENT} (agent model)", f"{MODEL_DEFAULT} (default)"],
"tasks solved": [f"{agent_solved}/{len(tasks)}", f"{default_solved}/{len(tasks)}"],
"avg steps": [f"{agent_steps:.1f}", f"{default_steps:.1f}"],
"cost $": [f"{agent_cost:.5f}", f"{default_cost:.5f}"],
})| model | tasks solved | avg steps | cost $ | |
|---|---|---|---|---|
| 0 | openai/gpt-4.1-mini (agent model) | 7/7 | 1.7 | 0.00242 |
| 1 | google/gemini-2.5-flash-lite (default) | 1/7 | 0.4 | 0.00055 |
This is the “agents need a model trained for reliable tool use” claim made concrete, and the gap is not subtle. The agent model solves most or all of the tasks by composing its tools, getting figures it could not reliably recall and arithmetic it could not reliably do (where it drops one, the trace shows a slip from which it recovers). The default model, the very one that excelled at single-prompt classification back in Chapter 2, solves few or none, and its failures trace to the loop itself, since the arithmetic is well within its reach: it forgets to call the tools, calls them with the wrong arguments, or tries to answer from memory. The avg steps column is the loop made visible: an agent doing real multi-step work averages a couple of tool calls per task, and a number far from that, in either direction, is a symptom worth investigating in the trace. The cost column makes the same point from the other side: the whole comparison cost well under a cent, so the gap, which price cannot explain at these amounts, comes from whether the model was trained to drive the loop at all. When an agent fails, the trace, the list of steps, tells you which of those went wrong, which is why an agent’s evaluation covers the path it took as well as the final answer; the Evaluate lab below puts that diagnosis in your hands.
If an agent misses a task, an assistant will readily propose another tool, a longer system prompt, or a retry wrapper, and one of those fixes might even work. But deciding what result.steps actually shows went wrong, whether a skipped call, a garbled argument, or an answer pulled from memory where a lookup was needed, is not a judgment you can hand back to the assistant that just watched the agent fail. Read the trace yourself before accepting any fix, because the fix that satisfies the assistant is not automatically the fix that addresses the failure you diagnosed.
An agent’s cost is driven by its loop. A few thousand tokens through the agent model still costs a fraction of a cent, a modest premium over the default; what grows the bill is that an agent makes several calls per question, each carrying the entire conversation so far, so a ten-step run pays for ten increasingly long contexts. On hardware you host, the same growth appears as latency and capacity: each call’s longer context takes longer to process and occupies the GPU for more of the run, so one agent question displaces many single-call questions. This is fine when the task needs it and ruinous when it does not, which is the whole reason the rule is to escalate to an agent only after the simpler designs have been ruled out.
The growth of “increasingly long” is quadratic, although the phrase suggests something linear, and the arithmetic shows why: if each step adds roughly the same number of tokens, call \(k\) pays for about \(k\) steps’ worth of context, and a run of \(N\) steps pays for about \(N(N+1)/2\) steps’ worth in total.
In Part IV we built workflows by hand and an agent loop, then connected tools through MCP. Frameworks exist for each, and most projects need fewer of them than it seems.
| Capability | Lightweight | Framework | Choose by |
|---|---|---|---|
| Orchestration | plain Python | LangGraph, LlamaIndex Workflows | how complex the control flow really is |
| Agent runtime | the book’s fifty-line loop | OpenAI Agents SDK, Pydantic AI, CrewAI | whether you need the loop managed for you |
| Tools | direct function calls | MCP via FastMCP and reference servers | reuse across clients |
Important
Common failure points
The current open-source and vendor options for these are in the tooling-landscape appendix, dated and fuller; model and provider choice is Appendix E.
What distinguishes an agent from a workflow?
In the chapter’s evaluation, the agent model solved most of the tasks and the default model solved almost none. The default model failed mainly by:
What problem does the Model Context Protocol solve?
When an agent misbehaves, the most useful thing to inspect is:
The right time to escalate from a workflow to an agent is when:
The evaluation runs the same agent on the default lightweight model as its baseline. What makes that the right baseline?
The chapter warns against deploying the eval-based calculator, especially over MCP, because:
eval is far too slow for production traffic at any real volumeIn the MCP server example, what work does the @mcp.tool() decorator remove?
Add a fourth tool to the agent, for example one that returns today’s date, or one that converts currencies with a fixed rate, and write a question that requires it together with the existing tools. Run the agent and read the trace to confirm it used the new tool. You may use an assistant to draft the tool and its schema; check the schema matches the function’s arguments yourself.
The evaluation showed the default model failing, but the table does not say why. Re-run one failing task on MODEL_DEFAULT and print result.steps, the trace of tool calls it made. Diagnose the failure mode from the trace: did it skip the lookup, call a tool with the wrong argument, or try the arithmetic in its head? Report which step broke, and write one sentence on why the trace is what you actually debug an agent with, given that the score only tells you a task was missed.
You can now build a fixed workflow from the five patterns, escalate to an agent when the task needs it, and give either one tools through MCP. These two projects show you the difference between the two.
Part V turns from answering questions to analyzing text and tables at scale.
That closes Part IV. We can build systems that read, retrieve, reason, and act. In Part V we step back to the data itself: the embeddings we have been using for search hold structure we can mine directly. In Chapter 13 we cluster them to discover the themes in a pile of text, no labels required.