Module 6: The SQL Agent Loop
Multi-Step: Exploring the Schema with a Tool, Then Querying
Description
Until now, the agent resolved things in a single tool loop: request run_sql, get rows, answer. But many questions need more than one loop. The most common reason: the model isn't sure what columns or tables exist, and before writing correct SQL it needs to look at the schema.
This lesson gives the agent two new exploration tools — list_tables (what tables exist?) and describe_table (what columns does this table have?) — and updates the runner so it can dispatch any tool the model requests, not just run_sql. With that, you'll see a multi-step agent in action: it explores the schema first (step 1), and only then writes and runs the correct run_sql (step 2), before answering (step 3). All of it traced, actually running against Reservo.
Connection to the module
Lesson 02 gave you the cycle with one tool; this one generalizes it to several. It's the same structure — the while with a cap, the tool_use → tool_result — but with a tool registry the runner looks up to know which function to run. Lessons 06 and 07 will keep using this multi-tool runner: 06 for the cap, 07 for self-correction.
Analogy: the librarian in an unfamiliar library
Imagine asking a librarian, in a library they don't know, "how many history books are there?" A reckless librarian would walk straight to a random shelf and start counting. A good one does two things first:
- Looks at the library's directory: "what sections are there?" (
list_tables). - Walks to the history section and reads its label: "how is this section organized, by author, by year?" (
describe_table).
And only then, knowing how the place is laid out, does the counting properly (run_sql). Exploring before acting isn't wasting time: it's what keeps you from counting the wrong shelf. The multi-step agent does exactly that: when the question calls for it, it explores the schema with dedicated tools first, and only afterward queries.
Two new tools: list_tables and describe_table
Just like run_sql, these tools have a contract (the description for the model) and an implementation (the real function). Let's start with the implementations, which reuse Module 2's schema catalog:
import sqlite3
def build_catalog(con):
"""{table: [columns]} dictionary of the real schema (M2)."""
tables = [r[0] for r in con.execute(
"SELECT name FROM sqlite_master WHERE type='table' "
"AND name NOT LIKE 'sqlite_%' ORDER BY name")]
return {t: [r[1] for r in con.execute(f"PRAGMA table_info({t})")] for t in tables}
def list_tables(con):
"""Tool: what tables does Reservo have?"""
return {"ok": True, "tables": list(build_catalog(con))}
def describe_table(name, con):
"""Tool: what columns (name and type) does this table have?"""
cat = build_catalog(con)
if name not in cat:
return {"ok": False, "error": f"table '{name}' doesn't exist. Tables: {list(cat)}"}
cols = con.execute(f"PRAGMA table_info({name})").fetchall()
return {"ok": True, "table": name,
"columns": [{"name": c[1], "type": c[2]} for c in cols]}
Their contracts, for the model, would look like this (shown together):
list_tables_tool = {
"name": "list_tables",
"description": "Returns the list of tables in the Reservo database. Takes no parameters.",
"input_schema": {"type": "object", "properties": {}},
}
describe_table_tool = {
"name": "describe_table",
"description": ("Returns the columns (name and type) of a Reservo table. "
"Use it to find out what columns exist before writing a SELECT."),
"input_schema": {
"type": "object",
"properties": {"name": {"type": "string", "description": "The name of the table."}},
"required": ["name"],
},
}
Notice describe_table's description: it literally suggests to the model when to use it — "before writing a SELECT." That's prompting: nudging the model toward the explore-then-query pattern.
Let's see them executed:
con = open_reservo()
print(json.dumps(list_tables(con), ensure_ascii=False))
print(json.dumps(describe_table("payments", con), ensure_ascii=False))
print(json.dumps(describe_table("reservations", con), ensure_ascii=False))
con.close()
What to expect:
{"ok": true, "tables": ["bookings", "members", "payments", "rooms"]}
{"ok": true, "table": "payments", "columns": [{"name": "id", "type": "INTEGER"}, {"name": "booking_id", "type": "INTEGER"}, {"name": "amount_cents", "type": "INTEGER"}, {"name": "kind", "type": "TEXT"}]}
{"ok": false, "error": "table 'reservations' doesn't exist. Tables: ['bookings', 'members', 'payments', 'rooms']"}
list_tables returns the four real tables. describe_table('payments') returns its columns with types — now the model knows amount_cents and kind exist. And describe_table('reservations') (the table that doesn't exist) returns a useful error that lists the real tables: if the model hallucinated the name, the error itself corrects it. All three, like run_sql, return the uniform {ok, ...} dictionary.
The runner, now with a tool registry
Lesson 02's runner only knew how to run run_sql. For multi-step, we generalize it with a registry: a name → function dictionary, so the runner can dispatch any tool the model requests.
import json
def make_tools(con):
"""Tool registry: the name the model requests -> the function that runs it."""
return {
"run_sql": lambda query: run_sql(query, con),
"list_tables": lambda: list_tables(con),
"describe_table": lambda name: describe_table(name, con),
}
def summarize(result):
"""Short summary of a tool result for the trace."""
if not result.get("ok"):
return f"ERROR: {result['error']}"
if "rows" in result:
return f"{len(result['rows'])} row(s): {result['rows']}"
if "tables" in result:
return f"tables: {result['tables']}"
if "columns" in result:
return f"columns: {[c['name'] for c in result['columns']]}"
return str(result)
And the updated runner. It changes little from lesson 02's: now a turn can carry a list of tool_calls (the model can request several at once), and each call gets dispatched through the registry:
def run_agent(question, con, model_script, max_iters=6):
tools = make_tools(con)
messages = [{"role": "user", "content": question}]
script = list(model_script)
print(f"Question: {question}")
for step in range(1, max_iters + 1):
# [MODEL] concept: the next turn from the script (what claude-sonnet-5 would return)
turn = script.pop(0) if script else {"type": "text", "text": "I couldn't continue."}
if turn["type"] == "text": # the model answers -> END
print(f" [step {step}] the model answers (without requesting tools) -> END")
print(f"Answer: {turn['text']}")
return turn["text"]
# the model requests one or more tools: dispatch through the registry
assistant_blocks = turn["tool_calls"]
tool_results = []
for call in assistant_blocks:
impl = tools[call["name"]] # looks up the function in the registry
result = impl(**call["input"]) # actually runs it
arg = call["input"].get("query") or call["input"].get("name") or ""
print(f" [step {step}] tool_use {call['name']}({arg!r})")
print(f" -> {summarize(result)}")
tool_results.append({"type": "tool_result", "tool_use_id": call["id"],
"content": json.dumps(result, ensure_ascii=False)})
messages.append({"role": "assistant", "content": assistant_blocks})
messages.append({"role": "user", "content": tool_results})
print(f" [cap] {max_iters} iterations reached -> forced END")
return "I couldn't solve it within the step cap."
The structure is the same — the for with a cap, the tool_use → tool_result — but now tools[call["name"]] lets the model choose between run_sql, list_tables, and describe_table. The **call["input"] unpacks the arguments: for run_sql it'll be query=..., for describe_table it'll be name=..., for list_tables nothing.
Worked example: a two-step question
Let's run a question that forces exploration: "How much has member Ana Torres paid in total?" Answering it requires knowing that payment money is in payments.amount_cents, and that a refund subtracts. The model (concept) isn't sure about payments's columns, so it describes it first and then writes the SELECT:
con = open_reservo()
script_b = [
# step 1: the model explores — what columns does payments have?
{"type": "tool_use", "tool_calls": [
{"id": "toolu_10", "name": "describe_table", "input": {"name": "payments"}}]},
# step 2: now with the columns, it writes the correct SELECT (charge adds, refund subtracts)
{"type": "tool_use", "tool_calls": [
{"id": "toolu_11", "name": "run_sql",
"input": {"query": (
"SELECT SUM(CASE WHEN p.kind='charge' THEN p.amount_cents "
"ELSE -p.amount_cents END) AS net_cents "
"FROM payments p JOIN bookings b ON b.id = p.booking_id "
"JOIN members m ON m.id = b.member_id WHERE m.name = 'Ana Torres'")}}]},
# step 3: answers
{"type": "text", "text": "Ana Torres has paid 24000 cents in total, that is $240.00."},
]
run_agent("How much has member Ana Torres paid in total?", con, script_b)
con.close()
What to expect:
Question: How much has member Ana Torres paid in total?
[step 1] tool_use describe_table('payments')
-> columns: ['id', 'booking_id', 'amount_cents', 'kind']
[step 2] tool_use run_sql("SELECT SUM(CASE WHEN p.kind='charge' THEN p.amount_cents ELSE -p.amount_cents END) AS net_cents FROM payments p JOIN bookings b ON b.id = p.booking_id JOIN members m ON m.id = b.member_id WHERE m.name = 'Ana Torres'")
-> 1 row(s): [[24000]]
[step 3] the model answers (without requesting tools) -> END
Answer: Ana Torres has paid 24000 cents in total, that is $240.00.
Read the three loops:
- Step 1 (explore). The model requests
describe_table('payments'). The runner actually runs it and observes the columns:['id', 'booking_id', 'amount_cents', 'kind']. Now the model knows the money is inamount_centsand thatkinddistinguisheschargefromrefund. That knowledge gets saved inmessages. - Step 2 (query). With the columns in view, the model writes the correct
SELECT— aJOINfrompayments→bookings→membersfiltering byAna Torres, addingcharges and subtractingrefunds. The runner runs it:[[24000]]. Ana paid a net 24000 cents (she has no refunds, so it's the sum of her charges). - Step 3 (answer). With the figure, the model answers:
$240.00.
Two tool loops, one answer loop. Step 1 didn't query data: it understood the schema. Without it, the model might have written payments.total (a nonexistent column) or added without subtracting the refunds. Exploring first is what makes the query robust.
When are 2-3 steps needed, and when is one enough?
Not every question needs exploration. The practical rule:
- One step when the model already knows the schema (because you gave it in the system prompt, as in M2) or the question is simple over a table it recalls well: "how many rooms are there?" → one
run_sqland done. - Two steps when the model needs to confirm columns or a data type before writing the SQL: "how much did Ana pay?" →
describe_table('payments'), thenrun_sql. - Three or more steps when the question crosses several tables whose schema the model isn't clear on: first
list_tables(what's there?), thendescribe_tablefor the relevant ones (how do they connect?), and only thenrun_sql. Or when the first SQL fails and needs correcting (lesson 07).
There's an interesting design tension here: if you give the model the complete schema in the system prompt (M2), it will almost never need list_tables/describe_table — it'll go straight to run_sql, saving loops. If you don't give it, exploring costs it steps but makes it autonomous on databases it doesn't know. The decision — how much schema to preload vs. how much to let it explore — is part of the agent's design. Here we show the exploration tools because a robust agent has them available just in case, even though many questions won't use them.
Boundary with AI Engineering. When an agent should explore vs. act directly, how it plans a sequence of steps, how it decides which tool to call — those are agent design questions in general, AI Engineering ecosystem territory. Here we stay in the SQL slice: giving it tools to look at the database's schema and letting the explore-then-query pattern emerge from the cycle.
Common mistakes
-
Forgetting to register the tool. If the script requests
describe_tablebut the registry (make_tools) doesn't include it,tools[call["name"]]raises aKeyError. Every tool the model might request must be in the registry and in thetools=[...]list passed to the model. Both lists must match. -
Giving exploration tools without describing when to use them. If
describe_table'sdescriptiondoesn't say "use this before writing aSELECT," the model might ignore it and jump straight to hallucinating columns. The description is what induces the explore-then-query pattern. -
Always exploring, even when it isn't needed. If the model calls
describe_tablefor every table on every question, it burns loops and tokens unnecessarily. A good system prompt with the schema (M2) reduces exploration to the cases that actually need it. Exploring is a safety net, not a mandatory ritual. -
Confusing "the model explored" with "the model got it right." The agent looking at the schema doesn't guarantee the final SQL is correct: it could describe
payments, seeamount_cents, and still forget to subtract therefunds. Exploration reduces one kind of error (hallucinated names), not all of them. Measuring accuracy is M7. -
list_tableswith aninput_schemathat requires parameters.list_tablestakes nothing; itsinput_schemais{"type": "object", "properties": {}}with norequired. If you mistakenly give it a required parameter, the model will try to fill it in and fail. Each contract must reflect exactly what the function needs.
Exercises
Exercise 1: Tracing the steps (Easy)
Without running anything, for the Ana Torres example: what was step 1's tool, and what was it for? Why didn't the model go straight to run_sql? How many tool loops were there in total?
See solution
Step 1's tool was describe_table('payments'), which let the model confirm payments's columns (amount_cents, kind) before writing the SQL. The model didn't go straight to run_sql because it wasn't sure what the money column was called or how to distinguish charges from refunds — exploring first avoids hallucinating payments.total or adding without subtracting refunds. There were two tool loops (step 1: describe_table; step 2: run_sql) and one answer loop (step 3).
Exercise 2: An agent that starts with list_tables (Medium)
Write a three-step script for "How many rooms are there?" where the model first calls list_tables (to discover the rooms table exists), then run_sql with the COUNT, and then answers. Run it.
See solution
con = open_reservo()
script = [
{"type": "tool_use", "tool_calls": [
{"id": "toolu_a", "name": "list_tables", "input": {}}]},
{"type": "tool_use", "tool_calls": [
{"id": "toolu_b", "name": "run_sql", "input": {"query": "SELECT COUNT(*) AS n FROM rooms"}}]},
{"type": "text", "text": "Reservo has 5 rooms."},
]
run_agent("How many rooms are there?", con, script)
con.close()
Expected output:
Question: How many rooms are there?
[step 1] tool_use list_tables('')
-> tables: ['bookings', 'members', 'payments', 'rooms']
[step 2] tool_use run_sql('SELECT COUNT(*) AS n FROM rooms')
-> 1 row(s): [[5]]
[step 3] the model answers (without requesting tools) -> END
Answer: Reservo has 5 rooms.
Explanation: Step 1 (list_tables) confirms a rooms table exists; the arg comes out empty because list_tables takes no parameters. Step 2 counts it ([[5]]). For such a simple question, starting with list_tables is more than necessary — an agent with the schema preloaded would go straight to run_sql — but the exercise shows the runner dispatches any registered tool with no changes needed.
Exercise 3: Two explorations in a single step (Hard)
The runner supports several tools in the same turn (tool_calls is a list). Write a script where step 1 requests both describe_table('bookings') and describe_table('members') at once, and step 2 is the text answer. Run it and observe that both execute in the same step.
See solution
con = open_reservo()
script = [
{"type": "tool_use", "tool_calls": [
{"id": "toolu_x", "name": "describe_table", "input": {"name": "bookings"}},
{"id": "toolu_y", "name": "describe_table", "input": {"name": "members"}}]},
{"type": "text", "text": "bookings joins to members through member_id."},
]
run_agent("How do bookings and members relate to each other?", con, script)
con.close()
Expected output:
Question: How do bookings and members relate to each other?
[step 1] tool_use describe_table('bookings')
-> columns: ['id', 'room_id', 'member_id', 'start_at', 'end_at', 'status', 'price_cents']
[step 1] tool_use describe_table('members')
-> columns: ['id', 'name', 'tier']
[step 2] the model answers (without requesting tools) -> END
Answer: bookings joins to members through member_id.
Explanation: The two describe_table calls live in the same turn (tool_calls with two elements), so both run and get printed under step 1 — the for call in assistant_blocks loop goes through both in a single loop iteration. Both tool_results (with their toolu_x and toolu_y ids) would travel in a single user message. The answer arrives on the next loop, step 2. This is the "request several tools at once" pattern from lesson 04: useful when the explorations are independent and one's result isn't needed to request the other. The step counter counts loop iterations, not tools: two tools in one loop share the same step number.
Summary and next step
- Many questions need more than one loop: the agent explores the schema first (
list_tables,describe_table) and only then writes the correctrun_sql. - The exploration tools reuse the schema catalog (M2) and return the same uniform
{ok, ...}dictionary asrun_sql. - The runner generalizes with a registry (
name → function): it dispatches any tool the model requests, and supports several in one turn. The cycle's structure doesn't change. - When to explore is a design decision: preloading the schema in the system prompt (M2) saves loops; letting the agent explore makes it autonomous on databases it doesn't know.
- Exploring reduces name hallucinations, it does not guarantee the final SQL is correct — that gets measured in M7.
Next lesson: When to stop and the iteration cap — You'll see in detail the two ways the loop ends (the model answers, or the cap cuts off a non-converging cycle), why the cap isn't optional, and the trace of an agent stuck on broken SQL until the cap stops it.
Additional resources
- SQLite — The Schema Table (
sqlite_master) — The list of real tableslist_tablesreturns. - SQLite — PRAGMA
table_info— The columns and typesdescribe_tablereturns. - Claude — Tool use: multiple tools — How the model chooses among several tools and can request several in one turn.
- Yao et al., "ReAct: Synergizing Reasoning and Acting in Language Models" — The pattern of interleaving reasoning (what should I explore?) and acting (I query) that shapes multi-step.