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_usetool_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:

  1. Looks at the library's directory: "what sections are there?" (list_tables).
  2. 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_usetool_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 in amount_cents and that kind distinguishes charge from refund. That knowledge gets saved in messages.
  • Step 2 (query). With the columns in view, the model writes the correct SELECT — a JOIN from paymentsbookingsmembers filtering by Ana Torres, adding charges and subtracting refunds. 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_sql and 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'), then run_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?), then describe_table for the relevant ones (how do they connect?), and only then run_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

  1. Forgetting to register the tool. If the script requests describe_table but the registry (make_tools) doesn't include it, tools[call["name"]] raises a KeyError. Every tool the model might request must be in the registry and in the tools=[...] list passed to the model. Both lists must match.

  2. Giving exploration tools without describing when to use them. If describe_table's description doesn't say "use this before writing a SELECT," the model might ignore it and jump straight to hallucinating columns. The description is what induces the explore-then-query pattern.

  3. Always exploring, even when it isn't needed. If the model calls describe_table for 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.

  4. 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, see amount_cents, and still forget to subtract the refunds. Exploration reduces one kind of error (hallucinated names), not all of them. Measuring accuracy is M7.

  5. list_tables with an input_schema that requires parameters. list_tables takes nothing; its input_schema is {"type": "object", "properties": {}} with no required. 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 correct run_sql.
  • The exploration tools reuse the schema catalog (M2) and return the same uniform {ok, ...} dictionary as run_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

  1. SQLite — The Schema Table (sqlite_master) — The list of real tables list_tables returns.
  2. SQLite — PRAGMA table_info — The columns and types describe_table returns.
  3. Claude — Tool use: multiple tools — How the model chooses among several tools and can request several in one turn.
  4. 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.