Módulo 6: El loop del agente SQL
El tool-calling: el contrato de `run_sql` y su JSON Schema
Descripción
En la cápsula anterior, el modelo "pedía" ejecutar run_sql. Pero, ¿cómo sabe el modelo que esa herramienta existe, qué recibe y qué devuelve? Esta cápsula responde eso: el tool-calling, y en concreto el contrato de la herramienta run_sql.
Un contrato de herramienta tiene dos lados. El primero es la descripción que le das al modelo —nombre, para qué sirve, y qué parámetros acepta, en formato JSON Schema—; con eso el modelo sabe que puede pedir "ejecuta run_sql con query = 'SELECT ...'". El segundo es la implementación —la función real que corre el SQL y devuelve el resultado—. Aquí es donde el módulo cierra el círculo con todo lo anterior: la función run_sql de M6 no reinventa nada; reúsa el validador del Módulo 4 (¿parsea? ¿es SELECT? ¿existen las columnas?) y los guardrails del Módulo 5 (conexión solo-lectura, tope de filas). Verás las dos mitades del contrato, y la función corriendo de verdad contra Reservo con una consulta buena, una alucinada y una destructiva.
Conexión con el módulo
La cápsula 02 te dio el ciclo (el while con tope); esta te da el motor de cada vuelta: la herramienta que el modelo invoca. Sin un contrato claro, el modelo no sabría qué puede pedir; sin una implementación blindada, el agente tendría una puerta abierta a la base de datos. La cápsula 04 mostrará la forma exacta del ida y vuelta con la Messages API; aquí nos concentramos en el contrato y en que la implementación reúsa M4+M5.
Analogía: el formulario de pedido y el almacén
Piensa en cómo pides algo a un almacén por un formulario. El formulario tiene un formato fijo: dice qué puedes pedir ("una consulta de solo lectura") y qué casilla llenar ("escribe tu query aquí, en texto"). No puedes pedir cualquier cosa de cualquier forma: el formulario define el contrato. El modelo, al ver el formulario, sabe exactamente qué llenar.
Del otro lado del mostrador está el almacenero, que recibe el formulario y hace el trabajo: revisa que el pedido sea válido (que no pidas "borra el inventario"), va al estante, y te trae la mercancía —o te devuelve una nota de "eso no existe"—. El formulario es la descripción de la herramienta (el JSON Schema); el almacenero es la implementación (la función run_sql). Y como el almacenero es la única persona con la llave del almacén, basta con que él revise cada pedido para que todo el sistema esté controlado. Ese es el papel de run_sql: una sola puerta, un solo lugar donde validar y blindar.
El primer lado del contrato: la descripción (JSON Schema)
Para que el modelo sepa que run_sql existe y cómo usarla, se la describes con un diccionario: nombre, descripción, y los parámetros que acepta en formato JSON Schema. Este es el contrato que enviarías al modelo junto con la pregunta:
run_sql_tool = {
"name": "run_sql",
"description": (
"Ejecuta una consulta SQL de SOLO LECTURA (SELECT) contra la base de datos "
"Reservo (SQLite) y devuelve las filas resultantes. Las tablas son: "
"rooms, members, bookings, payments. El dinero esta en centavos (INTEGER). "
"Devuelve {ok, columns, rows} si funciona, o {ok: false, error} si falla."
),
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "La consulta SQL SELECT a ejecutar.",
}
},
"required": ["query"],
},
}
Desmenúcemoslo, porque cada campo tiene un trabajo:
name("run_sql"): el identificador con el que el modelo pide la herramienta.description: el texto que el modelo lee para decidir cuándo y cómo usarla. Aquí metemos tres pistas de oro: que es solo lectura (SELECT), la lista de tablas (un adelanto del Módulo 2, el esquema como contexto, incrustado en la descripción para reducir alucinaciones de nombres), y la forma de lo que devuelve ({ok, columns, rows}o{ok: false, error}). Una buena descripción es prompting: cuanto más precisa, mejor decide el modelo.input_schema: el JSON Schema de los parámetros.type: "object"con una propiedadqueryde tipostring, yrequired: ["query"]para decir que es obligatoria. El modelo, al ver esto, sabe que debe rellenar exactamente un campoquerycon texto.
Ese diccionario es todo lo que el modelo necesita para pedir la herramienta correctamente. No ejecuta nada: describe. La ejecución es la otra mitad.
El segundo lado del contrato: la implementación real (M4 + M5 adentro)
Aquí está el aporte de este módulo: la función run_sql que de verdad corre el SQL. Y en vez de reinventar la validación y la seguridad, enchufa las piezas de los módulos 4 y 5. Empecemos por lo que reúsa.
Del Módulo 4, el validador de forma y esquema (¿parsea?, ¿es una sola sentencia?, ¿es SELECT?, ¿las columnas existen?):
import sqlite3, re, json
def strip_sql(sql):
"""Quita comentarios y espacios de los extremos (M4)."""
sql = re.sub(r"--[^\n]*", "", sql)
sql = re.sub(r"/\*.*?\*/", "", sql, flags=re.DOTALL)
return sql.strip()
def validate(sql, con):
"""Valida forma + esquema SIN ejecutar la consulta (M4). Devuelve (ok, error)."""
clean = strip_sql(sql)
if not clean:
return False, "consulta vacía"
body = clean.rstrip(";").strip()
if ";" in body: # ¿una sola sentencia?
return False, "más de una sentencia"
first = body.split(None, 1)[0].upper() # ¿es una lectura?
if first not in ("SELECT", "WITH"):
return False, f"no es de solo lectura (empieza con {first})"
try: # ¿parsea y las columnas existen?
con.execute("EXPLAIN " + body).fetchall() # EXPLAIN compila, no ejecuta
except sqlite3.Error as e:
return False, f"{type(e).__name__}: {e}"
return True, None
Del Módulo 5, el guardrail más fuerte: una conexión de solo lectura. PRAGMA query_only = ON hace que esa conexión no pueda escribir aunque alguien intente colar un DELETE:
def open_reservo(path="reservo.db"):
"""Abre Reservo en modo SOLO LECTURA (guardrail de M5)."""
con = sqlite3.connect(path)
con.execute("PRAGMA query_only = ON") # la conexión no puede escribir
return con
Y ahora, la herramienta que junta las dos mitades del contrato con la ejecución. Fíjate que valida (M4) antes de correr, corre sobre la conexión solo-lectura (M5), aplica un tope de filas (M5, para que una consulta no traiga millones de filas), y siempre devuelve el mismo diccionario uniforme:
def run_sql(query, con, max_rows=100):
"""La ÚNICA puerta del agente a Reservo. Valida (M4), corre con guardrails (M5),
y devuelve {ok, columns, rows, truncated} o {ok: false, error}."""
ok, err = validate(query, con) # M4: no toca la BD si el SQL es inválido
if not ok:
return {"ok": False, "error": err}
body = strip_sql(query).rstrip(";").strip()
try:
cur = con.execute(body) # M5: con es solo-lectura
rows = cur.fetchmany(max_rows + 1) # M5: tope de filas (trae una de más para detectar)
return {"ok": True,
"columns": [d[0] for d in cur.description],
"rows": [list(r) for r in rows[:max_rows]],
"truncated": len(rows) > max_rows}
except sqlite3.Error as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
Esa forma uniforme del retorno —ok, y luego o columns+rows, o error— es lo que la description le prometió al modelo. El agente siempre sabe cómo leer la respuesta, en éxito o en fallo. Y el error, cuando lo hay, trae el mensaje exacto del motor —el mismo no such column de M4— que en la cápsula 07 el modelo usará para corregir.
Ejemplo trabajado: run_sql ejecutada
Veamos las dos mitades del contrato funcionando juntas. Corremos run_sql con cuatro consultas: una buena, una con columna alucinada, una destructiva, y dos sentencias pegadas.
con = open_reservo()
# 1) Consulta buena
print(json.dumps(run_sql(
"SELECT name, capacity FROM rooms WHERE capacity >= 4 ORDER BY capacity", con),
ensure_ascii=False))
# 2) Columna alucinada (b.total no existe)
print(json.dumps(run_sql("SELECT SUM(b.total) FROM bookings b", con), ensure_ascii=False))
# 3) Destructiva (la allowlist de M4 la rechaza por no ser SELECT)
print(json.dumps(run_sql("DELETE FROM rooms", con), ensure_ascii=False))
# 4) Dos sentencias pegadas
print(json.dumps(run_sql("SELECT COUNT(*) FROM rooms; DROP TABLE rooms", con), ensure_ascii=False))
con.close()
Qué esperar:
{"ok": true, "columns": ["name", "capacity"], "rows": [["Studio", 4], ["Lounge", 6], ["Boardroom", 10]], "truncated": false}
{"ok": false, "error": "OperationalError: no such column: b.total"}
{"ok": false, "error": "no es de solo lectura (empieza con DELETE)"}
{"ok": false, "error": "más de una sentencia"}
Cuatro consultas, cuatro respuestas del mismo contrato:
- La buena devuelve
ok: truecon columnas y filas —Studio, Lounge, Boardroom—. El modelo puede formatear esto como respuesta. - La columna alucinada devuelve
ok: falsecon el error del motor. ElEXPLAINdevalidatela cazó antes de tocar los datos. El modelo sabe que falló y por qué —dato que en la cápsula 07 usará para pedir un SQL corregido—. - La destructiva ni siquiera llega al motor: la allowlist de M4 (
no es de solo lectura) la rechaza porque no empieza porSELECT/WITH. - Las dos sentencias pegadas se rechazan por forma:
más de una sentencia. ElDROP TABLEcolado nunca corre.
El guardrail que no depende de la allowlist: la conexión solo-lectura
La allowlist de M4 (no es de solo lectura) es un chequeo de texto: mira la primera palabra. Es rápido y suficiente, pero conviene tener una segunda línea de defensa a nivel de conexión, por si algún día una consulta escapara al chequeo de texto. Eso es PRAGMA query_only = ON: aunque un DELETE llegara al motor, el motor mismo lo bloquea.
con = open_reservo() # PRAGMA query_only = ON
try:
con.execute("DELETE FROM bookings") # saltándose run_sql, directo al motor
except sqlite3.Error as e:
print(f"{type(e).__name__}: {e}")
print("reservas intactas:", con.execute("SELECT COUNT(*) FROM bookings").fetchone()[0])
con.close()
Qué esperar:
OperationalError: attempt to write a readonly database
reservas intactas: 23
Aunque el DELETE llegue directo al motor —saltándose por completo el run_sql y su allowlist—, la conexión solo-lectura lo rechaza en seco: attempt to write a readonly database. Las 23 reservas siguen intactas. Dos capas para el mismo peligro (el texto de M4 y la conexión de M5) es exactamente la defensa en profundidad que hace segura la única puerta del agente. Los detalles de cada capa están en M4 y M5; aquí solo confirmamos que la herramienta las reúsa.
El tope de filas en acción
El otro guardrail de M5 que run_sql reúsa es el tope de filas: un agente no debería poder traer un millón de filas al contexto del modelo (caro y peligroso). El max_rows corta el resultado y marca truncated: true para que el modelo sepa que hay más:
con = open_reservo()
print(json.dumps(run_sql("SELECT name FROM rooms ORDER BY id", con, max_rows=3), ensure_ascii=False))
con.close()
Qué esperar:
{"ok": true, "columns": ["name"], "rows": [["Focus"], ["Studio"], ["Boardroom"]], "truncated": true}
Con max_rows=3, de las 5 salas solo vuelven 3, y truncated: true avisa que la lista está recortada. En Reservo las tablas son pequeñas y el tope por defecto (100) nunca se activa; el ejemplo baja el tope para verlo funcionar. El truco del fetchmany(max_rows + 1) —traer una fila de más— es lo que permite detectar el recorte sin contar toda la tabla.
Errores comunes
-
Creer que el modelo ejecuta el SQL. No lo hace. El modelo pide ejecutar
run_sql(rellenando elquerysegún elinput_schema); tu programa lo ejecuta. Esa separación es lo que te deja poner validación y guardrails: el poder de correr SQL está en tu función, no en el modelo. -
Una
descriptionpobre. Si la descripción no dice que es solo lectura, ni lista las tablas, ni la forma del retorno, el modelo alucina más nombres y malinterpreta los resultados. La descripción de la herramienta es prompting; escríbela con el mismo cuidado que un system prompt (M3). -
Reimplementar la validación dentro de
run_sql. El objetivo del módulo es reusar M4 y M5, no reescribirlos. La funciónrun_sqlllama avalidate(M4) y corre sobre la conexión deopen_reservo(M5). Si te encuentras copiando el chequeo deEXPLAINo delPRAGMA, para: eso ya existe. -
Dar acceso crudo a la base en vez de una herramienta controlada. Si
run_sqlfuera un simplecon.execute(query)sinvalidateniquery_only, sería la llave de destrucción. La gracia de canalizar todo por una función es que hay un solo lugar donde blindar. -
Devolver formatos distintos según el caso. Si a veces devuelves una lista de filas y a veces un string de error, el modelo (y tu runner) tienen que adivinar la forma. El diccionario uniforme
{ok, ...}que ladescriptionpromete es lo que hace predecible el ida y vuelta.
Ejercicios
Ejercicio 1: Leer el contrato (Fácil)
Dado el run_sql_tool de la cápsula, responde sin ejecutar nada: ¿cuántos parámetros acepta la herramienta, cuál es su nombre y tipo, y es obligatorio? ¿Qué tres pistas del esquema/comportamiento incluye la description?
Ver solución
Un solo parámetro: query, de tipo string, y es obligatorio (aparece en "required": ["query"]). La description incluye tres pistas: (1) que la consulta debe ser de solo lectura (SELECT), (2) la lista de tablas (rooms, members, bookings, payments) y que el dinero está en centavos —un adelanto del contexto de esquema del Módulo 2 metido en la herramienta—, y (3) la forma del retorno ({ok, columns, rows} o {ok: false, error}). Las tres reducen errores: menos alucinaciones de nombres, y un modelo que sabe cómo leer la respuesta.
Ejercicio 2: Probar el formato de éxito (Medio)
Usa run_sql para ejecutar SELECT tier, COUNT(*) AS n FROM members GROUP BY tier y muestra el diccionario que devuelve. Identifica cada clave.
Ver solución
con = open_reservo()
print(json.dumps(run_sql("SELECT tier, COUNT(*) AS n FROM members GROUP BY tier", con),
ensure_ascii=False))
con.close()
Salida esperada:
{"ok": true, "columns": ["tier", "n"], "rows": [["basic", 4], ["pro", 4]], "truncated": false}
Explicación: ok: true indica éxito; columns lista los nombres del resultado (["tier", "n"], el alias que pusimos); rows es la lista de filas (basic con 4, pro con 4); truncated: false dice que no hubo recorte. El agente lee ok primero para saber si formatear la respuesta o manejar un error. Esa uniformidad es lo que el contrato prometió.
Ejercicio 3: Un contrato para una herramienta de conteo (Difícil)
Diseña el contrato (el diccionario con name, description, input_schema) de una herramienta hipotética count_rows que recibe un nombre de tabla y devuelve cuántas filas tiene. No la implementes; solo escribe el contrato y explica cómo el modelo sabría usarla.
Ver solución
count_rows_tool = {
"name": "count_rows",
"description": (
"Cuenta las filas de una tabla de Reservo y devuelve el total. "
"Tablas validas: rooms, members, bookings, payments. "
"Devuelve {ok, count} si funciona, o {ok: false, error} si la tabla no existe."
),
"input_schema": {
"type": "object",
"properties": {
"table": {
"type": "string",
"enum": ["rooms", "members", "bookings", "payments"],
"description": "El nombre de la tabla a contar.",
}
},
"required": ["table"],
},
}
Explicación: El modelo, al ver este contrato, sabe que puede pedir count_rows con un campo table. La novedad frente a run_sql es el "enum": restringe los valores válidos a las cuatro tablas reales, así el modelo no puede pedir una tabla inventada —el JSON Schema mismo acota lo que el modelo puede rellenar—. La description repite la lista de tablas y la forma del retorno. Este patrón —una herramienta más específica, con un enum que cierra las opciones— es el otro extremo de run_sql (que es genérica y acepta cualquier SELECT): cuanto más específica la herramienta, menos libertad tiene el modelo para equivocarse, pero menos preguntas puede responder. El diseño de esa balanza es materia del ecosistema de AI Engineering; aquí basta con saber leer y escribir el contrato.
Resumen y siguiente paso
- El tool-calling tiene un contrato de dos lados. La descripción (nombre,
description,input_schemaen JSON Schema) le dice al modelo qué puede pedir y cómo; la implementación es la función real que corre. - La
descriptiones prompting: incluir que es solo lectura, la lista de tablas y la forma del retorno reduce alucinaciones y ayuda al modelo a leer los resultados. - La función
run_sqlde M6 reúsa M4 y M5: valida antes de correr (validate), corre sobre una conexión solo-lectura (PRAGMA query_only = ON) y aplica un tope de filas. No reinventa ninguna de las dos capas. - El retorno es un diccionario uniforme —
{ok, columns, rows, truncated}o{ok: false, error}— que el contrato promete y el agente siempre sabe leer. - Dos capas para el destructivo (la allowlist de texto de M4 y la conexión solo-lectura de M5) es defensa en profundidad sobre la única puerta del agente.
Siguiente cápsula: La forma tool_use → tool_result de la Messages API — Verás, como concepto con claude-sonnet-5, la forma exacta del bloque tool_use con que el modelo pide la herramienta y del tool_result con que le devuelves el resultado; y confirmarás que la función que se ejecutaría es la run_sql real de esta cápsula.
Recursos adicionales
- Claude — Tool use (function calling) — El
input_schema, cómo el modelo lee ladescriptionpara decidir, y la forma deltool_use. - JSON Schema — El formato con el que se describe el
input_schemade una herramienta (type,properties,required,enum). - SQLite — PRAGMA
query_only— El guardrail de M5 que hace la conexión de solo lectura; la segunda capa contra lo destructivo. - SQLite —
EXPLAIN— El chequeo de M4 quevalidatereúsa para saber si el SQL parsea y referencia columnas reales sin ejecutarlo. - Python —
sqlite3.Cursor.fetchmany— De dónde sale el tope de filas: traermax_rows + 1para detectar el recorte.