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:

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:

  1. Declarar. En cada request pasás tools=[...]: una lista de herramientas, cada una con name, description y un input_schema en JSON Schema que define los parámetros.

  2. El modelo pide. Si decide usar una herramienta, la respuesta llega con stop_reason == "tool_use", y en response.content hay 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.
  3. 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 assistant con el response.content completo (¡tal cual vino!),
    • un turno user cuyo contenido es una lista de bloques tool_result, cada uno con el tool_use_id correspondiente 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".

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:

  1. 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.
  2. Decí cuándo NO. El registrar_pedido dice "NUNCA la uses para cotizar". Sin eso, el modelo a veces registra pedidos cuando el cliente solo preguntaba el total.
  3. 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.
  4. Describí también cada parámetro. El description de cada propiedad guía cómo el modelo la llena (p. ej. "tal como aparece en el menú").
  5. 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:

  1. response.content completo, tal cual. El turno assistant que guardás debe contener los bloques tool_use originales (con sus IDs). El SDK acepta los objetos del response directamente en messages — no los conviertas a texto ni los reconstruyas.

  2. Un tool_result por cada tool_use, con el ID correcto, todos en UN turno user. Si el modelo pidió 3 herramientas y respondés 2, error 400. Si los repartís en varios turnos user, error 400 (rompe la alternancia). El tool_use_id es lo que le permite al modelo aparear cada resultado con su pedido.

  3. Errores como tool_result con is_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 (un tool_use sin tool_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