Herramientas: el bot toma acciones
"El modelo nunca toca tu base de datos. Solo pide, con buenos modales y en JSON, que vos lo hagás por él."
Qué vas a aprender en este capítulo
El bot del capítulo 2 conversa precioso, pero cuando le preguntan el precio de la pupusa de ayote dice "lo voy a confirmar"... y nunca confirma nada, porque no tiene cómo. En este capítulo le damos manos: la capacidad de consultar el menú real, calcular pedidos con aritmética exacta, registrarlos en disco y verificar horarios. Son los requisitos R1 a R4 del proyecto.
La técnica se llama tool use (o function calling) y es, junto con RAG, el patrón más importante del desarrollo con LLMs. Vas a aprender:
- El contrato exacto: cómo declarás herramientas (JSON Schema) y cómo el modelo pide usarlas.
- El loop agéntico: el ciclo request → tool_use → ejecutar → tool_result → respuesta, robusto ante varias tools por turno y ante errores.
- El oficio de escribir descripciones de herramientas que digan cuándo usarlas — la diferencia entre un bot que las usa con criterio y uno que las ignora o abusa.
3.1 Qué es function calling (y qué no es)
💡 Intuición
Volvamos a Marta, la empleada estrella. Cuando un cliente pregunta "¿a cuánto la de ayote?", Marta no se sabe el precio de memoria — se da vuelta y mira la pizarra. La pizarra no es parte de Marta: es una herramienta que ella decide consultar cuando la necesita.
Tool use es exactamente eso, con una vuelta de tuerca importante: el modelo no tiene manos. No puede mirar la pizarra, ni escribir en el cuaderno de pedidos, ni tocar tu disco. Lo único que puede hacer es pedirte que lo hagas: "necesito que ejecutés consultar_menu con categoria='pupusas' y me digás qué dio". Tu código ejecuta la función Python de verdad, le devuelve el resultado, y el modelo redacta la respuesta final con ese dato.
Esto es una característica de seguridad, no una limitación: vos controlás el 100% de lo que se ejecuta. El modelo propone; tu código dispone.
📐 Fundamento
El contrato tiene tres movimientos:
-
Declarar. En cada request pasás
tools=[...]: una lista de herramientas, cada una conname,descriptiony uninput_schemaen JSON Schema que define los parámetros. -
El modelo pide. Si decide usar una herramienta, la respuesta llega con
stop_reason == "tool_use", y enresponse.contenthay uno o más bloques con.type == "tool_use". Cada bloque trae:.id— identificador único de esta invocación (lo vas a necesitar para responder),.name— qué herramienta pidió,.input— los argumentos, ya parseados como dict de Python.
-
Vos ejecutás y devolvés. Corrés tu función con esos argumentos y devolvés el resultado en un nuevo request con dos mensajes agregados:
- el turno
assistantcon elresponse.contentcompleto (¡tal cual vino!), - un turno
usercuyo contenido es una lista de bloquestool_result, cada uno con eltool_use_idcorrespondiente y el resultado como string.
Y volvés a llamar a la API. El modelo lee el resultado y responde — o pide otra herramienta, y el ciclo se repite hasta que
stop_reason == "end_turn". - el turno
A ese ciclo se le llama loop agéntico: el modelo encadena acciones hasta completar la tarea.
Mermaid
flowchart TB
A["Tu app: messages.create(..., tools=[...])"] --> B{"stop_reason?"}
B -->|"end_turn"| C["✅ Respuesta final de texto
→ mostrarla al usuario"]
B -->|"tool_use"| D["Por CADA bloque tool_use en content:
ejecutar la función Python con bloque.input"]
D --> E["messages.append(assistant: response.content)
messages.append(user: [tool_result, ...])"]
E --> A
D -.->|"la función lanza excepción"| F["tool_result con is_error: true
(el modelo se entera y reacciona)"]
F --> E
3.2 Los datos: el menú de La Esquina
Las herramientas necesitan datos reales sobre los que operar. En producción esto sería una base de datos; para el libro, un módulo Python con diccionarios — la interfaz de las herramientas no cambia cuando migrés a SQL. Creá datos_la_esquina.py:
"""Datos de La Esquina: menu, sucursales y registro de pedidos."""
import json
from datetime import datetime
from pathlib import Path
MENU = {
"pupusas": {
"queso": 0.75,
"frijol con queso": 0.75,
"chicharron": 0.85,
"revuelta": 0.85,
"ayote": 0.85,
"jalapeño con queso": 0.90,
"loroco con queso": 0.90,
"camaron": 1.25,
},
"bebidas": {
"horchata": 1.00,
"ensalada": 1.00,
"cafe": 0.75,
"gaseosa": 1.00,
"agua": 0.50,
},
"extras": {
"curtido extra": 0.25,
"salsa extra": 0.25,
"queso fundido": 1.50,
},
}
HORARIOS = {
"centro": {"abre": 7, "cierra": 20, "dias": "lunes a domingo"},
"roosevelt": {"abre": 10, "cierra": 21, "dias": "lunes a domingo"},
"metrocentro": {"abre": 9, "cierra": 19, "dias": "lunes a sábado"},
}
ARCHIVO_PEDIDOS = "pedidos.json"
def buscar_precio(nombre_item: str):
"""Busca un item por nombre en todo el menu. Devuelve (categoria, precio) o None."""
nombre = nombre_item.lower().strip()
for categoria, items in MENU.items():
if nombre in items:
return categoria, items[nombre]
return None
def guardar_pedido(pedido: dict) -> int:
"""Agrega un pedido al archivo JSON y devuelve su numero."""
ruta = Path(ARCHIVO_PEDIDOS)
pedidos = json.loads(ruta.read_text(encoding="utf-8")) if ruta.exists() else []
pedido["numero"] = len(pedidos) + 1
pedido["registrado_en"] = datetime.now().isoformat(timespec="seconds")
pedidos.append(pedido)
ruta.write_text(json.dumps(pedidos, ensure_ascii=False, indent=2),
encoding="utf-8")
return pedido["numero"]
3.3 Definir las herramientas: JSON Schema + descripciones que enseñan
Acá está el corazón del capítulo. Cada herramienta se declara con un dict. Prestá especial atención a las description: no describen solo qué hace la herramienta, sino cuándo usarla — esa es la señal que el modelo usa para decidir. Creá herramientas.py:
"""Definiciones (schemas) e implementaciones de las herramientas del bot."""
import json
from datetime import datetime
import datos_la_esquina as datos
# ---------------------------------------------------------------
# 1. SCHEMAS: lo que el modelo ve. Las descripciones dicen CUANDO usarlas.
# ---------------------------------------------------------------
TOOLS = [
{
"name": "consultar_menu",
"description": (
"Devuelve los platillos y precios del menú de La Esquina. "
"Usala SIEMPRE que el cliente pregunte qué hay, precios, o "
"mencione un platillo — nunca respondas precios de memoria. "
"Si el cliente pregunta por algo general ('¿qué venden?'), "
"consultá sin categoría para traer todo."
),
"input_schema": {
"type": "object",
"properties": {
"categoria": {
"type": "string",
"enum": ["pupusas", "bebidas", "extras"],
"description": "Categoría a consultar. Omitila para traer el menú completo.",
},
},
"required": [],
},
},
{
"name": "calcular_pedido",
"description": (
"Calcula el total exacto de un pedido con los precios oficiales. "
"Usala SIEMPRE antes de decirle un total al cliente — no hagás "
"aritmética vos. También sirve para verificar que los items "
"existan en el menú antes de confirmar."
),
"input_schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"description": "Items del pedido con su cantidad.",
"items": {
"type": "object",
"properties": {
"nombre": {
"type": "string",
"description": "Nombre del item tal como aparece en el menú, p. ej. 'revuelta'.",
},
"cantidad": {
"type": "integer",
"description": "Cuántas unidades. Mínimo 1.",
},
},
"required": ["nombre", "cantidad"],
},
},
},
"required": ["items"],
},
},
{
"name": "registrar_pedido",
"description": (
"Registra un pedido confirmado en el sistema y devuelve el número "
"de pedido. Usala SOLO cuando ya tengás los tres datos: items, "
"nombre del cliente y sucursal, Y el cliente haya confirmado "
"explícitamente que quiere ordenar. NUNCA la uses para cotizar."
),
"input_schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"description": "Items confirmados del pedido.",
"items": {
"type": "object",
"properties": {
"nombre": {"type": "string"},
"cantidad": {"type": "integer"},
},
"required": ["nombre", "cantidad"],
},
},
"nombre_cliente": {
"type": "string",
"description": "Nombre de quien recoge el pedido.",
},
"sucursal": {
"type": "string",
"enum": ["centro", "roosevelt", "metrocentro"],
"description": "Sucursal donde se recoge.",
},
},
"required": ["items", "nombre_cliente", "sucursal"],
},
},
{
"name": "verificar_horario",
"description": (
"Devuelve el horario de una sucursal y si está abierta en este "
"momento. Usala cuando pregunten horarios, si están abiertos, o "
"a qué hora cierran. No adivines horarios."
),
"input_schema": {
"type": "object",
"properties": {
"sucursal": {
"type": "string",
"enum": ["centro", "roosevelt", "metrocentro"],
"description": "Sucursal a verificar.",
},
},
"required": ["sucursal"],
},
},
]
# ---------------------------------------------------------------
# 2. IMPLEMENTACIONES: el Python real que se ejecuta.
# ---------------------------------------------------------------
def consultar_menu(categoria: str | None = None) -> str:
if categoria:
items = datos.MENU.get(categoria)
if items is None:
raise ValueError(f"Categoría desconocida: {categoria!r}")
return json.dumps({categoria: items}, ensure_ascii=False)
return json.dumps(datos.MENU, ensure_ascii=False)
def calcular_pedido(items: list[dict]) -> str:
detalle, total, no_encontrados = [], 0.0, []
for item in items:
nombre = item["nombre"]
cantidad = int(item["cantidad"])
if cantidad < 1:
raise ValueError(f"Cantidad inválida para {nombre!r}: {cantidad}")
resultado = datos.buscar_precio(nombre)
if resultado is None:
no_encontrados.append(nombre)
continue
_, precio = resultado
subtotal = round(precio * cantidad, 2)
total += subtotal
detalle.append({"item": nombre, "cantidad": cantidad,
"precio_unitario": precio, "subtotal": subtotal})
return json.dumps({
"detalle": detalle,
"total": round(total, 2),
"items_no_encontrados": no_encontrados,
}, ensure_ascii=False)
def registrar_pedido(items: list[dict], nombre_cliente: str, sucursal: str) -> str:
# Re-validamos TODO en el servidor: nunca confíes solo en el modelo.
calculo = json.loads(calcular_pedido(items))
if calculo["items_no_encontrados"]:
raise ValueError(
f"Items fuera del menú: {calculo['items_no_encontrados']}. "
"No se registró el pedido."
)
if not nombre_cliente.strip():
raise ValueError("Falta el nombre del cliente.")
numero = datos.guardar_pedido({
"items": calculo["detalle"],
"total": calculo["total"],
"cliente": nombre_cliente.strip(),
"sucursal": sucursal,
})
return json.dumps({"numero_pedido": numero, "total": calculo["total"],
"sucursal": sucursal}, ensure_ascii=False)
def verificar_horario(sucursal: str) -> str:
info = datos.HORARIOS.get(sucursal)
if info is None:
raise ValueError(f"Sucursal desconocida: {sucursal!r}")
hora_actual = datetime.now().hour
abierta = info["abre"] <= hora_actual < info["cierra"]
return json.dumps({
"sucursal": sucursal,
"horario": f"{info['abre']}:00 a {info['cierra']}:00, {info['dias']}",
"abierta_ahora": abierta,
}, ensure_ascii=False)
# Registro nombre → funcion, usado por el loop del bot.
IMPLEMENTACIONES = {
"consultar_menu": consultar_menu,
"calcular_pedido": calcular_pedido,
"registrar_pedido": registrar_pedido,
"verificar_horario": verificar_horario,
}
🛠️ En la práctica
El oficio de las descripciones. El modelo decide usar (o no) una herramienta leyendo su description — es prompt engineering, no documentación. Reglas destiladas de bots en producción:
- Decí cuándo, no solo qué. "Devuelve el menú" es débil; "Usala SIEMPRE que pregunten precios — nunca respondas precios de memoria" cambia el comportamiento de forma medible.
- Decí cuándo NO. El
registrar_pedidodice "NUNCA la uses para cotizar". Sin eso, el modelo a veces registra pedidos cuando el cliente solo preguntaba el total. enum> texto libre. Con"enum": ["centro", "roosevelt", "metrocentro"], el modelo no puede inventar la sucursal "San Migue Centro". Cada parámetro que pueda ser enum, que lo sea.- Describí también cada parámetro. El
descriptionde cada propiedad guía cómo el modelo la llena (p. ej. "tal como aparece en el menú"). - Pocas herramientas, bien descritas. 4 herramientas claras superan a 15 confusas: con muchas opciones solapadas el modelo duda o elige mal.
Y la regla de oro de la implementación: revalidá todo en tu código. registrar_pedido recalcula el total y verifica los items aunque el modelo "ya lo hizo". El modelo es un usuario más de tu API interna: amable, pero no confiable al 100%.
⚠️ Trampa común
Dejar que el modelo haga la aritmética. "Son 3 de queso a $0.75 y 2 horchatas a $1.00... son $4.50" — suena bien, ¡y es incorrecto! (es $4.25). Los LLMs generan texto plausible, y la aritmética plausible se equivoca lo suficiente como para costarte clientes. Por eso existe calcular_pedido: los números los hace Python, que no improvisa. Regla general: todo lo que tenga una respuesta exacta (cálculos, fechas, datos del negocio) va a una herramienta; el modelo redacta, no calcula.
3.4 El loop agéntico completo
Ahora conectamos las herramientas al bot del capítulo 2. El método nuevo, enviar(), implementa el ciclo del diagrama: llama a la API en un while, y mientras el modelo siga pidiendo herramientas, las ejecuta y le devuelve resultados. Creá bot_v3.py:
"""Bot de La Esquina v3: memoria + personalidad + HERRAMIENTAS (tool use)."""
import json
from pathlib import Path
from dotenv import load_dotenv
import anthropic
from herramientas import TOOLS, IMPLEMENTACIONES
load_dotenv()
MAX_TURNOS = 30 # los ciclos de tools agregan turnos; damos mas margen
MAX_CICLOS_TOOLS = 8 # tope de iteraciones del loop agentico por mensaje
SYSTEM_PROMPT = """Sos "Esquinita", el asistente virtual de La Esquina, pupusería \
con 3 sucursales en San Miguel, El Salvador.
# Tu rol
Atendés clientes por chat: menú, precios, horarios y pedidos.
# Herramientas
Tenés herramientas para consultar el menú, calcular totales, registrar pedidos
y verificar horarios. Usalas en vez de responder de memoria. Los datos que
devuelven son la única fuente de verdad sobre precios y horarios.
# Tono
- Cordial salvadoreño: "con gusto", "fíjese que", "¡cómo no!". Trato de "usted".
- Respuestas cortas (1-3 oraciones), un emoji máximo (😊 🫓).
# Reglas del negocio
- Para registrar un pedido necesitás: items, nombre del cliente y sucursal.
Pedí solo lo que falte. Antes de registrar, confirmá el pedido con el total.
- Si un item no está en el menú, ofrecé lo más parecido que sí esté.
# Qué NO hacés
- Nunca inventés precios ni platillos: consultá el menú.
- No registrés pedidos sin confirmación explícita del cliente.
- No hablés de temas ajenos a la pupusería; redirigí con amabilidad.
# Formato
- Pedidos: items con viñetas y total al final. Sin tablas ni markdown pesado.
"""
class Chatbot:
"""Chatbot con historial y loop agentico de herramientas."""
def __init__(self, system_prompt: str, model: str = "claude-opus-4-8"):
self.client = anthropic.Anthropic()
self.system_prompt = system_prompt
self.model = model
self.historial: list[dict] = []
# ---------- memoria (cap. 2) ----------
def _recortar_historial(self) -> list[dict]:
if len(self.historial) <= MAX_TURNOS:
return self.historial
recorte = self.historial[-MAX_TURNOS:]
# No empezar en assistant NI dejar un tool_result huerfano al inicio:
while recorte and (
recorte[0]["role"] == "assistant"
or (isinstance(recorte[0].get("content"), list)
and any(getattr(b, "type", b.get("type") if isinstance(b, dict) else "")
== "tool_result" for b in recorte[0]["content"]))
):
recorte = recorte[1:]
return recorte
# ---------- herramientas ----------
def _ejecutar_tool(self, bloque) -> dict:
"""Ejecuta un bloque tool_use y devuelve el tool_result correspondiente."""
funcion = IMPLEMENTACIONES.get(bloque.name)
if funcion is None:
return {
"type": "tool_result",
"tool_use_id": bloque.id,
"content": f"Herramienta desconocida: {bloque.name}",
"is_error": True,
}
try:
resultado = funcion(**bloque.input) # .input ya es un dict
return {
"type": "tool_result",
"tool_use_id": bloque.id,
"content": resultado,
}
except Exception as e:
# El error viaja al modelo, que puede corregir y reintentar.
return {
"type": "tool_result",
"tool_use_id": bloque.id,
"content": f"Error al ejecutar {bloque.name}: {e}",
"is_error": True,
}
# ---------- el loop agentico ----------
def enviar(self, mensaje_usuario: str) -> str:
self.historial.append({"role": "user", "content": mensaje_usuario})
for _ in range(MAX_CICLOS_TOOLS):
response = self.client.messages.create(
model=self.model,
max_tokens=1024,
system=self.system_prompt,
tools=TOOLS,
messages=self._recortar_historial(),
)
if response.stop_reason != "tool_use":
# end_turn (o max_tokens/refusal): respuesta final de texto.
texto = "".join(b.text for b in response.content
if b.type == "text")
self.historial.append({"role": "assistant", "content": texto})
return texto
# El modelo pidio herramientas (puede pedir VARIAS en un turno).
# 1) Guardar el turno assistant COMPLETO, con sus bloques tool_use:
self.historial.append({"role": "assistant",
"content": response.content})
# 2) Ejecutar cada tool_use y juntar todos los resultados:
resultados = [
self._ejecutar_tool(bloque)
for bloque in response.content
if bloque.type == "tool_use"
]
# 3) Devolverlos en UN solo turno user:
self.historial.append({"role": "user", "content": resultados})
# ... y el for vuelve a llamar a la API con todo el contexto.
# Tope de ciclos alcanzado: cortar con elegancia.
texto = ("Disculpe, se me complicó procesar eso 😅 "
"¿Me lo repite de otra forma?")
self.historial.append({"role": "assistant", "content": texto})
return texto
def main():
bot = Chatbot(SYSTEM_PROMPT)
print("🫓 Esquinita v3 (con herramientas) — 'salir' para terminar.\n")
while True:
entrada = input("Vos: ").strip()
if entrada.lower() in ("salir", "exit", "quit"):
print("¡Que le vaya bien!")
break
if not entrada:
continue
print(f"\nBot: {bot.enviar(entrada)}\n")
if __name__ == "__main__":
main()
Probálo con una conversación de pedido completa:
Vos: ¿A cuánto están las de loroco?
Bot: ¡Con gusto! La pupusa de loroco con queso está a 0.90 😊
Vos: Dame 4 de esas y 2 horchatas. Soy Beatriz, paso por la del centro.
Bot: ¡Cómo no, Beatriz! Su pedido sería:
• 4 pupusas de loroco con queso — 3.60
• 2 horchatas — 2.00
Total: 5.60 dólares, para recoger en la sucursal Centro. ¿Se lo confirmo?
Vos: Sí, confirmado.
Bot: ¡Listo! Su pedido quedó registrado con el número 1. 🫓
Detrás de escena, ese segundo mensaje disparó dos herramientas (consultar_menu para verificar el loroco y calcular_pedido para el total), y el tercero disparó registrar_pedido — que escribió de verdad en pedidos.json. Abrí el archivo y comprobalo.
📐 Fundamento
Los tres puntos donde el loop se rompe si no sabés lo que hacés:
-
response.contentcompleto, tal cual. El turnoassistantque guardás debe contener los bloquestool_useoriginales (con sus IDs). El SDK acepta los objetos del response directamente enmessages— no los conviertas a texto ni los reconstruyas. -
Un
tool_resultpor cadatool_use, con el ID correcto, todos en UN turnouser. Si el modelo pidió 3 herramientas y respondés 2, error 400. Si los repartís en varios turnosuser, error 400 (rompe la alternancia). Eltool_use_ides lo que le permite al modelo aparear cada resultado con su pedido. -
Errores como
tool_resultconis_error: True, no como excepciones. Si tu función lanza una excepción y dejás que mate el programa, la conversación queda corrupta (untool_usesintool_result). Capturá la excepción y mandala como resultado de error: el modelo la lee, entiende qué pasó, y reacciona — reintenta con otros argumentos o le explica el problema al cliente. Probálo: pedí "10 pupusas de pollo" (no existe) y mirá cómo el modelo usa el error para ofrecer alternativas.
⚠️ Trampa común
Olvidar el append del response.content antes del tool_result. El error más común del capítulo. La secuencia en messages debe ser:
user: "¿a cuánto las de queso?"
assistant: [tool_use id=abc, consultar_menu, ...] ← ESTE turno se te olvida
user: [tool_result tool_use_id=abc, "{...precios...}"]
Si mandás el tool_result sin el turno assistant previo, la API responde 400: hay un resultado que no corresponde a ningún pedido. El nombre del error te lo va a decir (unexpected tool_use_id), pero el reflejo de "guardo solo el texto del assistant" viene del capítulo 2 y cuesta desaprenderlo: con herramientas, el contenido del assistant es la lista de bloques completa, no un string.
⚠️ Trampa común
El loop infinito de herramientas. Si una herramienta devuelve siempre error, o el modelo entra en un ciclo de "consulto → no me convence → vuelvo a consultar", tu while puede no terminar nunca — y cada vuelta cuesta dinero. Por eso MAX_CICLOS_TOOLS = 8: un tope duro con salida amable. En producción, además, logueá cuántos ciclos usa cada mensaje; si el promedio sube, algo anda mal en tus descripciones o en tus datos.
3.5 Probar las herramientas sin gastar tokens
Las implementaciones son funciones Python puras: se testean sin tocar la API (gratis y en milisegundos). Creá test_herramientas.py:
"""Tests de las herramientas — sin llamar a la API, sin gastar un centavo."""
import json
from herramientas import calcular_pedido, consultar_menu, verificar_horario
def test_calculo_simple():
r = json.loads(calcular_pedido([{"nombre": "queso", "cantidad": 3},
{"nombre": "horchata", "cantidad": 2}]))
assert r["total"] == 4.25
assert r["items_no_encontrados"] == []
def test_item_inexistente():
r = json.loads(calcular_pedido([{"nombre": "pizza", "cantidad": 1}]))
assert r["items_no_encontrados"] == ["pizza"]
assert r["total"] == 0
def test_menu_por_categoria():
r = json.loads(consultar_menu("bebidas"))
assert "horchata" in r["bebidas"]
def test_horario_estructura():
r = json.loads(verificar_horario("centro"))
assert r["sucursal"] == "centro"
assert isinstance(r["abierta_ahora"], bool)
if __name__ == "__main__":
test_calculo_simple()
test_item_inexistente()
test_menu_por_categoria()
test_horario_estructura()
print("✅ Todos los tests pasaron.")
Esta separación — schemas y loop por un lado, lógica de negocio testeable por el otro — es el diseño que vas a ver en cualquier base de código seria de agentes.
Resumen visual
| Pieza | Quién la produce | Qué contiene |
|---|---|---|
tools=[...] |
Vos, en cada request | name + description (¡cuándo usarla!) + input_schema (JSON Schema) |
stop_reason == "tool_use" |
El modelo | "Quiero ejecutar herramientas antes de responder" |
Bloque tool_use |
El modelo | .id, .name, .input (dict ya parseado) |
Turno assistant con response.content |
Vos (append) | Los bloques tal cual vinieron — con los tool_use adentro |
Turno user con tool_results |
Vos (append) | Un bloque por cada tool_use, con su tool_use_id; errores con is_error: True |
stop_reason == "end_turn" |
El modelo | Fin del ciclo: la respuesta de texto está lista |
Estado del proyecto — R1 (menú sin inventar) ✅, R2 (totales exactos) ✅, R3 (registrar pedidos) ✅, R4 (horarios) ✅, más R6 y R7 del capítulo anterior. Falta que el bot conozca las políticas del negocio (R5) — ¿qué pasa si preguntan por entregas a domicilio o cómo encargar 200 pupusas para un cumpleaños? Eso es el capítulo 4: RAG.
Ejercicios
✏️ Ejercicio 1 — Leé la mente del bot
Agregá al loop de enviar() un modo debug: cuando DEBUG = True, imprimí cada bloque tool_use (nombre y argumentos) y cada tool_result (primeros 80 caracteres) antes de continuar el ciclo. Corré el pedido de Beatriz de la sección 3.4 y anotá la secuencia exacta de herramientas. ¿El modelo consultó el menú antes de calcular? ¿Por qué eso es buena señal?
✅ Solución
Dentro del loop, después de detectar tool_use:
if DEBUG:
for b in response.content:
if b.type == "tool_use":
print(f" 🔧 {b.name}({json.dumps(b.input, ensure_ascii=False)})")
for r in resultados:
if DEBUG:
print(f" ↩️ {str(r['content'])[:80]}{' [ERROR]' if r.get('is_error') else ''}")
Secuencia típica: consultar_menu(categoria='pupusas') → calcular_pedido(items=[...]) → respuesta. Que consulte el menú primero es buena señal: está verificando que "loroco" exista y a qué precio antes de calcular, en vez de confiar en su memoria — exactamente lo que la description de consultar_menu le pide ("nunca respondas precios de memoria"). Si tu bot se salta ese paso, revisá la description.
✏️ Ejercicio 2 — Agregá la herramienta `consultar_pedido`
Doña Carmen pide poder responder "¿ya está mi pedido?". Agregá una herramienta consultar_pedido(numero_pedido) que lea pedidos.json y devuelva los datos del pedido (o error si no existe). Escribí el schema completo (con description al estilo del capítulo: cuándo usarla), la implementación y registrala en IMPLEMENTACIONES. Probá: "¿me decís qué lleva el pedido 1?".
✅ Solución
Schema:
{
"name": "consultar_pedido",
"description": (
"Devuelve los datos de un pedido ya registrado (items, total, sucursal, "
"cliente) a partir de su número. Usala cuando el cliente pregunte por "
"el estado o contenido de un pedido existente. NO sirve para crear ni "
"modificar pedidos."
),
"input_schema": {
"type": "object",
"properties": {
"numero_pedido": {"type": "integer",
"description": "Número de pedido que recibió el cliente."},
},
"required": ["numero_pedido"],
},
}
Implementación:
def consultar_pedido(numero_pedido: int) -> str:
from pathlib import Path
ruta = Path(datos.ARCHIVO_PEDIDOS)
if not ruta.exists():
raise ValueError("Todavía no hay pedidos registrados.")
pedidos = json.loads(ruta.read_text(encoding="utf-8"))
for p in pedidos:
if p["numero"] == numero_pedido:
return json.dumps(p, ensure_ascii=False)
raise ValueError(f"No existe el pedido número {numero_pedido}.")
Y IMPLEMENTACIONES["consultar_pedido"] = consultar_pedido más el schema en TOOLS. El error con is_error hace que, ante "¿qué lleva el pedido 99?", el bot responda "fíjese que no encuentro ese número" en vez de explotar.
✏️ Ejercicio 3 — El bug del tool_result perdido
Un compañero modificó el loop así y ahora la API le devuelve error 400 cuando el modelo pide dos herramientas a la vez:
self.historial.append({"role": "assistant", "content": response.content})
for bloque in response.content:
if bloque.type == "tool_use":
resultado = self._ejecutar_tool(bloque)
self.historial.append({"role": "user", "content": [resultado]})
¿Qué está mal y por qué con UNA herramienta sí funciona?
✅ Solución
Está agregando un turno user por cada herramienta. Con dos tools el historial queda assistant, user, user — dos user consecutivos → 400 (roles must alternate). Con una sola herramienta hay un solo user, la alternancia se respeta y el bug queda invisible (por eso es traicionero: pasa los tests simples).
Lo correcto: juntar todos los tool_result en una lista y agregar un único turno user con todos adentro, como hace bot_v3.py. Bonus: la API exige además que cada tool_use del turno assistant tenga su result en el turno user siguiente — completo o nada.
✏️ Ejercicio 4 — Sabotaje controlado
Hacé que verificar_horario lance RuntimeError("base de datos caída") siempre. Conversá con el bot y preguntá "¿está abierta la del centro?". ¿Qué le dice el bot al cliente? Después cambiá is_error: True por False en _ejecutar_tool (mandando el mismo texto de error como resultado "exitoso") y repetí. ¿Cambia el comportamiento? ¿Qué aprendés sobre para qué sirve is_error?
✅ Solución
Con is_error: True, el modelo entiende que la herramienta falló y responde con disculpa honesta tipo "fíjese que ahorita no puedo verificar el horario, pero la sucursal Centro normalmente abre temprano" — o evita afirmar datos.
Con is_error: False, el texto "Error al ejecutar..." llega como si fuera un resultado válido; el modelo a veces lo interpreta bien igual (el texto es elocuente), pero es más propenso a confundirse o a citar el error literal al cliente. is_error es metadata semántica: le dice al modelo cómo interpretar el contenido (resultado vs falla), lo que afecta su decisión de reintentar, disculparse o cambiar de estrategia. Usalo siempre que la ejecución falle — es parte del contrato, no decoración.
Para profundizar
- Tool use en la documentación oficial — platform.claude.com/docs, sección Tool use: el contrato completo,
tool_choice(forzar o prohibir herramientas), tool use con streaming y herramientas del lado del servidor. - JSON Schema — json-schema.org: la especificación de
input_schema. Contype,enum,requiredydescriptioncubrís el 95% de los casos reales. - Capítulo 6 del libro de IA — LLMs y transformers, sección de agentes: el contexto conceptual de los loops agénticos.
- Anthropic Cookbook — github.com/anthropics/anthropic-cookbook: notebooks oficiales con patrones avanzados de tool use (orquestación, herramientas paralelas, validación).