---
title: "6.4.1 Agentes con herramientas: ReAct y planificación"
description: "Tool calling y bucle ReAct con herramientas deterministas (precios, noticias, calculadora): LLM local, APIs simuladas y fallos con sus guardarraíles."
url: "https://pythonparatrading.com/curso-python-trading/nivel-6/agentes-con-herramientas-react-y-planificacion/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-6/agentes-con-herramientas-react-y-planificacion.png"
---

# 6.4.1 · Agentes con herramientas: tool calling, ReAct y planificación

> Un agente es un bucle: el modelo pide una herramienta, el programa la ejecuta y le devuelve el resultado, hasta que el modelo responde. Esta lección construye ese bucle con tres herramientas deterministas sobre los datos del nivel, lo ejecuta con un LLM local de 0,5B y con las APIs de OpenAI y Anthropic simuladas, y cataloga cómo falla: herramienta equivocada, argumentos inventados, bucles y respuestas que ignoran lo que devolvieron las herramientas.

- Curso: [Nivel 6: IA generativa, LLMs, agentes, big data y datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/)
- Módulo: 6.4 · Agentes de IA para trading
- Duración: 70 min
- Requisitos: [6.2.1 · Cómo funciona un LLM: del siguiente token a la API](https://pythonparatrading.com/curso-python-trading/nivel-6/como-funcionan-los-llm-apis-y-modelos-locales/), [6.2.2 · Prompting estructurado: JSON que se puede validar](https://pythonparatrading.com/curso-python-trading/nivel-6/prompting-estructurado-y-json-con-pydantic/)

## Al terminar esta lección

- Explicar qué es el tool calling: el modelo no ejecuta nada, emite una petición estructurada (nombre y argumentos JSON) que el programa valida y ejecuta.
- Definir herramientas deterministas con esquemas Pydantic y construir un bucle ReAct con validación, límite de pasos y detección de repeticiones.
- Ejecutar el mismo bucle con un LLM local (formato de la plantilla de Qwen) y con las formas documentadas de las APIs de OpenAI (Responses) y Anthropic (Messages) contra simuladores locales.
- Reconocer los modos de fallo típicos y verificar la respuesta final contra los resultados de las herramientas, y saber cuándo un plan fijo es mejor que dejar decidir al modelo.

## Un agente es un bucle con herramientas

Un LLM solo produce texto. Para que consulte un precio, busque noticias o haga una cuenta exacta, se le da una lista
de **herramientas** descritas con un nombre, una explicación y un esquema JSON de sus argumentos. Cuando el modelo
decide usar una, no la ejecuta: escribe una **petición estructurada** («llama a `precio` con `ticker=BUSI` y
`fecha=2024-03-15`»). El programa que rodea al modelo valida esa petición, ejecuta la función, y le devuelve el
resultado como un mensaje más. El modelo lee el resultado y decide si pide otra herramienta o responde. Eso es el
*tool calling* (o *function calling*), y el bucle completo es un **agente**.

```mermaid
flowchart TB
  U[Pregunta] --> M[LLM]
  M --> D{¿Pide herramienta?}
  D -->|sí| V[Validar argumentos]
  V --> E[Ejecutar en código]
  E --> O[Observación]
  O --> M
  D -->|no| R[Respuesta final]
  R --> C[Verificar]
```

El patrón **ReAct** (Yao et al., 2022) le dio nombre a esta forma de trabajar: alternar razonamiento (*reason*) y
acción (*act*), con la observación de cada acción como entrada del siguiente razonamiento. Frente a pedir la respuesta
de una vez, ReAct permite que el modelo use datos que no están en sus pesos y que corrija el rumbo con lo que
observa. Frente a ReAct está la **planificación explícita**: primero se decide el plan completo (qué herramientas, en
qué orden) y después se ejecuta. Cuando la tarea es conocida, el plan lo puede fijar el programador y el modelo solo
rellena los huecos.

Dos ideas guían toda la lección:

1. **El programa manda.** Cada llamada pasa por código que valida los argumentos, decide si se ejecuta y registra lo
   que ocurrió. El modelo propone; el código dispone. La lección 6.4.5 lleva esta idea al límite con un motor de
   riesgo determinista entre el agente y las órdenes.
2. **Las herramientas deben ser deterministas.** Mismo argumento, mismo resultado: así la traza de un agente se puede
   repetir, auditar y testear, y los fallos son del modelo o del diseño, no de la herramienta.

## Tres herramientas deterministas sobre los datos del nivel

Las herramientas trabajan sobre los datos sintéticos del nivel 6: los cierres diarios de las 120 empresas del panel
(`acciones6_precios.parquet`) y el corpus de noticias (`noticias6_corpus.parquet`). Cada una tiene un modelo Pydantic
para sus argumentos (lección 6.2.2): el esquema JSON que verá el modelo sale de ahí, y la validación también.

```python
import ast
import json
import operator
import re

import numpy as np
import pandas as pd
from pydantic import BaseModel, Field, ValidationError

precios = pd.read_parquet("acciones6_precios.parquet")
noticias = pd.read_parquet("noticias6_corpus.parquet")
TICKERS = set(precios.ticker)
FECHA = r"^\d{4}-\d{2}-\d{2}$"


class ArgsPrecio(BaseModel):
    ticker: str = Field(pattern=r"^[A-Z]{4}$")
    fecha: str = Field(pattern=FECHA)


class ArgsNoticias(BaseModel):
    ticker: str = Field(pattern=r"^[A-Z]{4}$")
    desde: str = Field(pattern=FECHA)
    hasta: str = Field(pattern=FECHA)


class ArgsCalculadora(BaseModel):
    expresion: str


def precio(ticker, fecha):
    if ticker not in TICKERS:
        raise ValueError(f"ticker desconocido: {ticker}")
    s = precios[(precios.ticker == ticker) & (precios.fecha <= fecha)]
    if s.empty:
        raise ValueError(f"sin datos hasta {fecha}")
    r = s.iloc[-1]
    return {"ticker": ticker, "fecha": f"{r.fecha:%Y-%m-%d}",
            "cierre": round(float(r.cierre), 2)}


def buscar_noticias(ticker, desde, hasta):
    if ticker not in TICKERS:
        raise ValueError(f"ticker desconocido: {ticker}")
    ini = pd.Timestamp(desde, tz="UTC")
    fin = pd.Timestamp(hasta, tz="UTC") + pd.Timedelta(days=1)
    x = noticias[(noticias.ticker == ticker) & (noticias.ts >= ini)
                 & (noticias.ts < fin)].drop_duplicates("titular")
    return [{"fecha": f"{t:%Y-%m-%d}", "titular": h}
            for t, h in zip(x.ts[:5], x.titular[:5])]


OPS = {ast.Add: operator.add, ast.Sub: operator.sub,
       ast.Mult: operator.mul, ast.Div: operator.truediv,
       ast.USub: operator.neg}


def _evalua(n):
    if isinstance(n, ast.Constant) and type(n.value) in (int, float):
        return n.value
    if isinstance(n, ast.BinOp) and type(n.op) in OPS:
        return OPS[type(n.op)](_evalua(n.left), _evalua(n.right))
    if isinstance(n, ast.UnaryOp) and type(n.op) in OPS:
        return OPS[type(n.op)](_evalua(n.operand))
    raise ValueError("solo números, + - * / y paréntesis")


def calculadora(expresion):
    """Aritmética sin eval(): se recorre el árbol de la expresión."""
    return round(_evalua(ast.parse(expresion, mode="eval").body), 4)


HERRAMIENTAS = {
    "precio": (precio, ArgsPrecio, "Cierre de una acción en una "
               "sesión (la última sesión en o antes de la fecha)."),
    "buscar_noticias": (buscar_noticias, ArgsNoticias, "Titulares de "
                        "noticias de una acción entre dos fechas "
                        "(AAAA-MM-DD)."),
    "calculadora": (calculadora, ArgsCalculadora, "Evalúa una "
                    "expresión aritmética con + - * / y paréntesis."),
}


def ejecutar(nombre, args):
    """Valida y ejecuta; los errores vuelven como datos, no como
    excepciones, para que el modelo los vea."""
    if nombre not in HERRAMIENTAS:
        return {"error": f"herramienta desconocida: {nombre}"}
    f, Args, _ = HERRAMIENTAS[nombre]
    try:
        a = Args.model_validate(args)
    except ValidationError as e:
        return {"error": "argumentos no válidos: " + "; ".join(
            f"{x['loc'][0]}: {x['msg']}" for x in e.errors())}
    try:
        return {"resultado": f(**a.model_dump())}
    except Exception as e:
        return {"error": str(e)}


print(ejecutar("precio", {"ticker": "BUSI", "fecha": "2024-03-16"}))
print(ejecutar("precio", {"ticker": "BUSI", "fecha": "16/03/2024"}))
print(ejecutar("calculadora", {"expresion": "(27.88/26.19 - 1)*100"}))
print(ejecutar("calculadora",
               {"expresion": "__import__('os').getcwd()"}))
print(ejecutar("buscar_noticias", {"ticker": "BUSI",
               "desde": "2024-03-18", "hasta": "2024-03-20"}))
```
```output
{'resultado': {'ticker': 'BUSI', 'fecha': '2024-03-15', 'cierre': 26.19}}
{'error': "argumentos no válidos: fecha: String should match pattern '^\\d{4}-\\d{2}-\\d{2}$'"}
{'resultado': 6.4528}
{'error': 'solo números, + - * / y paréntesis'}
{'resultado': [{'fecha': '2024-03-19', 'titular': 'Un fondo ofrece comprar Busmar Hogar'}]}
```

Cuatro decisiones de diseño que se repiten en cualquier agente serio:

- **Los errores son datos.** Una fecha en formato europeo o un ticker inexistente no lanzan una excepción que tumbe el
  programa: vuelven como `{"error": …}`, que el modelo leerá como observación y podrá corregir.
- **Sin `eval`.** La calculadora recorre el árbol sintáctico y solo admite números y cuatro operaciones. Un modelo (o
  un texto inyectado en una noticia que el modelo lee) no puede ejecutar código por esa vía.
- **Fechas y sesiones explícitas.** El 16 de marzo de 2024 es sábado: `precio` devuelve el cierre del viernes 15 y
  dice qué fecha usó. Una herramienta que respondiera en silencio con otra sesión sería fuente de errores difíciles de
  ver.
- **Resultados acotados.** `buscar_noticias` quita las copias sindicadas (mismo titular) y devuelve como mucho cinco
  titulares. Lo que vuelve de una herramienta entra en el contexto del modelo en cada paso siguiente.

## El tool calling por dentro: la plantilla del modelo local

Con un modelo local se ve que el tool calling no es magia: es texto. La plantilla de chat de Qwen2.5-0.5B-Instruct
(Apache-2.0, el modelo pequeño del entorno `ia`; lección 6.2.1) mete los esquemas de las herramientas en el mensaje de
sistema entre etiquetas `<tools>` y pide al modelo que conteste con `<tool_call>{"name": …, "arguments": …}</tool_call>`.
El modelo fue ajustado para seguir ese formato; el programa tiene que extraer las llamadas del texto y comprobar que
son JSON válido. La documentación de Qwen lo advierte: no está garantizado que el modelo siga siempre el protocolo, y
en producción conviene parsear y validar por cuenta propia.

```python
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
modelo = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(modelo)
llm = AutoModelForCausalLM.from_pretrained(modelo, dtype=torch.float32)


def esquema(nombres):
    """Herramientas en el formato de funciones de la plantilla."""
    return [{"type": "function", "function": {
        "name": n, "description": HERRAMIENTAS[n][2],
        "parameters": HERRAMIENTAS[n][1].model_json_schema()}}
        for n in nombres]


SISTEMA = ("Eres un asistente de research de renta variable. Usa las "
           "herramientas para obtener datos; no inventes cifras. Cuando "
           "tengas la respuesta, contesta en español en una o dos frases.")
mensajes = [{"role": "system", "content": SISTEMA},
            {"role": "user", "content": "¿Cierre de BUSI el 2024-03-15?"}]
texto = tok.apply_chat_template(mensajes, tools=esquema(["precio"]),
                                add_generation_prompt=True,
                                tokenize=False)
print(texto[texto.index("# Tools"):texto.index("<|im_start|>user")])
```
```output
# Tools

You may call one or more functions to assist with the user query.

You are provided with function signatures within <tools></tools> XML tags:
<tools>
{"type": "function", "function": {"name": "precio", "description": "Cierre de una acción en una sesión (la última sesión en o antes de la fecha).", "parameters": {"properties": {"ticker": {"pattern": "^[A-Z]{4}$", "title": "Ticker", "type": "string"}, "fecha": {"pattern": "^\\d{4}-\\d{2}-\\d{2}$", "title": "Fecha", "type": "string"}}, "required": ["ticker", "fecha"], "title": "ArgsPrecio", "type": "object"}}}
</tools>

For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call><|im_end|>
```

## Un bucle ReAct con el LLM local

El bucle es corto: generar, buscar etiquetas `<tool_call>`, ejecutar cada llamada con `ejecutar` y devolver el
resultado como mensaje con rol `tool`; si no hay llamadas, esa es la respuesta final. Los guardarraíles mínimos van
dentro del bucle:

- **Límite de pasos** (`max_pasos`): un agente que no converge se corta.
- **JSON inválido** y **herramienta desconocida**: vuelven como error.
- **Llamada repetida** con los mismos argumentos: no se ejecuta otra vez; se le recuerda al modelo que ya tiene el
  resultado.
- **Traza**: cada llamada, con sus argumentos y su resultado, queda registrada para auditar y verificar.

Dos tareas con su trampa: una variación porcentual (dos precios y una cuenta) y el cierre de un ticker que no existe.
Cada paso del modelo pequeño cuesta varios segundos de CPU, así que la lección se queda en dos; la herramienta de
noticias se usa en el ejercicio 1.

```python
LLAMADA = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.S)


def agente_local(pregunta, nombres, max_pasos=5):
    msgs = [{"role": "system", "content": SISTEMA},
            {"role": "user", "content": pregunta}]
    traza, vistos = [], set()
    for paso in range(1, max_pasos + 1):
        e = tok.apply_chat_template(msgs, tools=esquema(nombres),
                                    add_generation_prompt=True,
                                    return_tensors="pt",
                                    return_dict=True)
        with torch.inference_mode():
            out = llm.generate(**e, max_new_tokens=80, do_sample=False,
                               pad_token_id=tok.eos_token_id)
        n = e["input_ids"].shape[1]
        txt = tok.decode(out[0, n:], skip_special_tokens=True).strip()
        llamadas = LLAMADA.findall(txt)
        print(f"  paso {paso} ({n} tokens de entrada)")
        if not llamadas:
            return txt, traza
        msgs.append({"role": "assistant", "content": txt})
        for crudo in llamadas:
            try:
                c = json.loads(crudo)
                nombre, args = c["name"], c.get("arguments", {})
            except (json.JSONDecodeError, KeyError, TypeError):
                nombre, args = None, None
            clave = json.dumps([nombre, args], sort_keys=True)
            if nombre is None:
                r = {"error": "llamada con JSON no válido"}
            elif clave in vistos:
                r = {"error": "llamada repetida: ya tienes el resultado"}
            else:
                r = ejecutar(nombre, args)
            vistos.add(clave)
            traza.append((nombre, args, r))
            print("    ", nombre, args, "=>",
                  json.dumps(r, ensure_ascii=False)[:70])
            msgs.append({"role": "tool",
                         "content": json.dumps(r, ensure_ascii=False)})
    return None, traza


BASICAS = ["precio", "buscar_noticias", "calculadora"]
TAREAS = [
    "¿Cuánto cambió en porcentaje el cierre de BUSI entre el "
    "2024-03-15 y el 2024-03-22?",
    "¿Cuál fue el cierre de ACME el 2024-03-15?"]
resultados = []
for pregunta in TAREAS:
    print(pregunta)
    final, traza = agente_local(pregunta, BASICAS)
    resultados.append((pregunta, final, traza))
    print("  RESPUESTA:", final)
```
```output
¿Cuánto cambió en porcentaje el cierre de BUSI entre el 2024-03-15 y el 2024-03-22?
  paso 1 (567 tokens de entrada)
     precio {'ticker': 'BUSI', 'fecha': '2024-03-15'} => {"resultado": {"ticker": "BUSI", "fecha": "2024-03-15", "cierre": 26.1
     precio {'ticker': 'BUSI', 'fecha': '2024-03-22'} => {"resultado": {"ticker": "BUSI", "fecha": "2024-03-22", "cierre": 27.8
  paso 2 (734 tokens de entrada)
  RESPUESTA: El cambio en el cierre del producto BUSI entre el 2024-03-15 y el 2024-03-22 fue de 27.88%.
¿Cuál fue el cierre de ACME el 2024-03-15?
  paso 1 (548 tokens de entrada)
     precio {'ticker': 'ACME', 'fecha': '2024-03-15'} => {"error": "ticker desconocido: ACME"}
  paso 2 (612 tokens de entrada)
  RESPUESTA: El cierre del ticker ACME el 2024-03-15 fue de $10.
```

En la primera tarea el modelo hace bien lo que parecía difícil: pide los dos cierres en el mismo turno (dos
`<tool_call>` seguidos), con el ticker y las fechas correctos, y recibe 26,19 y 27,88. Y falla en lo que parecía fácil:
no llama a la calculadora y responde que el cambio «fue de 27.88%», el segundo cierre convertido en porcentaje. La
variación real es del 6,45 %. En la segunda, el ticker no existe y la herramienta lo dice; el modelo recibe el error y
responde que el cierre «fue de \$10». Ningún guardarraíl del bucle salta: las llamadas eran válidas, no hubo
repeticiones y sobraron pasos. Los tokens de entrada crecen en cada paso (de 567 a 734 en la primera tarea) porque el
modelo vuelve a leer toda la conversación, resultados incluidos.

## Verificar la respuesta final contra la traza

Los guardarraíles del bucle no ven el error más peligroso, porque está en la respuesta final y no en una llamada. La
respuesta se puede verificar contra la traza con dos reglas sencillas, deterministas y baratas:

1. Toda cifra de la respuesta tiene que aparecer en algún resultado correcto de una herramienta (o en la pregunta).
2. Todo porcentaje tiene que salir de una herramienta que calcula (aquí, la calculadora), no de la cabeza del modelo.

Y una tercera, de proceso: si una herramienta devolvió un error y nada lo resolvió después, la respuesta no puede dar
el dato que esa herramienta debía traer.

```python
NUM = re.compile(r"\d[\d.,]*\d|\d")


def cifras(s):
    out = []
    for t in NUM.findall(s or ""):
        if "," in t:
            t = t.replace(".", "").replace(",", ".")
        elif re.fullmatch(r"\d{1,3}(\.\d{3})+", t):
            t = t.replace(".", "")
        out.append(float(t.rstrip(".")))
    return out


def cerca(x, ys):
    return any(abs(x - y) <= 0.006 * max(1.0, abs(y)) for y in ys)


def verificar(pregunta, final, traza):
    if final is None:
        return ["sin respuesta: límite de pasos"]
    vistas, calculadas, errores = [], [], []
    for nombre, args, r in traza:
        if "resultado" in r:
            vistas += cifras(json.dumps(r["resultado"]))
            if nombre in ("calculadora", "variacion"):
                calculadas += cifras(json.dumps(r["resultado"]))
        else:
            errores.append(nombre)
    fallos = []
    for x in cifras(final):
        if not cerca(x, vistas + cifras(pregunta)):
            fallos.append(f"cifra sin fuente: {x:g}")
    for p in re.findall(r"(\d[\d.,]*)\s*%", final):
        if not cerca(cifras(p)[0], calculadas):
            fallos.append(f"porcentaje sin cálculo: {p}%")
    if errores and not vistas:
        fallos.append("error de herramienta sin resolver")
    return fallos


for pregunta, final, traza in resultados:
    print(pregunta[:45], "|", verificar(pregunta, final, traza) or "OK")
```
```output
¿Cuánto cambió en porcentaje el cierre de BUS | ['porcentaje sin cálculo: 27.88%']
¿Cuál fue el cierre de ACME el 2024-03-15? | ['cifra sin fuente: 10', 'error de herramienta sin resolver']
```

El verificador rechaza las dos respuestas: el 27,88 % no sale de ningún cálculo, y el 10 no sale de ningún resultado
y además quedó un error sin resolver. Qué se hace con una respuesta rechazada depende del uso: devolver el motivo al
modelo como una observación más y darle otro intento, contestar «no se ha podido verificar» o pasar el caso a una
persona. Lo que no se hace es enseñarla como buena. Las reglas son pocas y burdas (una cifra puede estar en la traza y
aun así estar mal usada), pero cuestan microsegundos y cazan los dos fallos más caros: cifras inventadas y cuentas no
hechas.

## Planificar en vez de improvisar

Cuando la tarea es conocida («variación de un precio entre dos fechas»), no hace falta que el modelo descubra el plan
en cada ejecución. Hay dos formas de fijarlo:

- **Una herramienta de más alto nivel.** `variacion(ticker, desde, hasta)` hace las dos consultas y la cuenta en
  código. El modelo solo tiene que extraer tres parámetros de la pregunta y transcribir el resultado.
- **Planificar y ejecutar.** El programa tiene el plan (precio inicial, precio final, cuenta, informe) y el modelo
  solo rellena los parámetros, o solo redacta al final. Es el patrón *plan-and-execute*: menos libertad, menos fallos,
  trazas más predecibles.

```mermaid
flowchart TB
  P[Pregunta] --> E[LLM extrae datos]
  E --> V[Validar esquema]
  V --> H[Cálculo en código]
  H --> R[LLM redacta]
  R --> C[Verificar]
```

```python
class ArgsVariacion(BaseModel):
    ticker: str = Field(pattern=r"^[A-Z]{4}$")
    desde: str = Field(pattern=FECHA)
    hasta: str = Field(pattern=FECHA)


def variacion(ticker, desde, hasta):
    a, b = precio(ticker, desde), precio(ticker, hasta)
    return {"desde": a, "hasta": b, "variacion_pct":
            round(100 * (b["cierre"] / a["cierre"] - 1), 2)}


HERRAMIENTAS["variacion"] = (
    variacion, ArgsVariacion, "Variación porcentual del cierre de una "
    "acción entre dos fechas (AAAA-MM-DD), calculada con los dos "
    "cierres.")
final, traza = agente_local(TAREAS[0], ["variacion", "buscar_noticias"])
print("  RESPUESTA:", final)
print("  verificación:", verificar(TAREAS[0], final, traza) or "OK")
```
```output
  paso 1 (533 tokens de entrada)
     variacion {'ticker': 'BUSI', 'desde': '2024-03-15', 'hasta': '2024-03-22'} => {"resultado": {"desde": {"ticker": "BUSI", "fecha": "2024-03-15", "cie
  paso 2 (691 tokens de entrada)
  RESPUESTA: El cambio en el cierre de la acción BUSI entre el 2024-03-15 y el 2024-03-22 fue de 6.45%.
  verificación: OK
```

Con `variacion` el modelo hace una sola llamada con los tres parámetros correctos, recibe la variación calculada en
código y la transcribe: 6,45 %, y el verificador la da por buena. Es el mismo modelo con el mismo prompt de sistema; lo
único que ha cambiado es que ya no tiene que encadenar tres llamadas ni hacer una cuenta. En un agente, el diseño de
las herramientas pesa tanto como el modelo: cuanto más alto el nivel de la herramienta, menos decisiones quedan en
manos del modelo y menos formas hay de fallar. El precio es la flexibilidad: `variacion` no sirve para preguntas que no
se previeron, y para esas sigue haciendo falta un bucle ReAct (con su verificador).

## Las APIs comerciales: mismas ideas, otra forma

Las APIs de OpenAI y Anthropic hacen lo mismo con mensajes estructurados en lugar de etiquetas en el texto:

| | OpenAI (Responses API) | Anthropic (Messages API) |
|---|---|---|
| Definir una herramienta | `{"type": "function", "name", "description", "parameters"}` | `{"name", "description", "input_schema"}` |
| El modelo pide una llamada | elemento `{"type": "function_call", "call_id", "name", "arguments"}` (argumentos como texto JSON) | bloque `{"type": "tool_use", "id", "name", "input"}` (argumentos como objeto) y `stop_reason: "tool_use"` |
| Devolver el resultado | `{"type": "function_call_output", "call_id", "output"}` en `input` | bloque `{"type": "tool_result", "tool_use_id", "content"}` en un mensaje de usuario (`is_error` para errores) |
| Varias llamadas a la vez | posibles (`parallel_tool_calls`) | posibles; todos los resultados en un solo mensaje |

Formas según la documentación de cada proveedor, consultada el 2026-10-23[^1]. El código real, que no se ejecuta aquí
porque necesita claves y red:

```python skip
from openai import OpenAI

cliente = OpenAI()                      # OPENAI_API_KEY del entorno
herramientas = [{"type": "function", "name": n,
                 "description": HERRAMIENTAS[n][2],
                 "parameters": HERRAMIENTAS[n][1].model_json_schema()}
                for n in BASICAS]
entrada = [{"role": "user", "content": TAREAS[0]}]
r = cliente.responses.create(model="gpt-5", input=entrada,
                             tools=herramientas)
for item in r.output:
    if item.type == "function_call":
        res = ejecutar(item.name, json.loads(item.arguments))
        entrada += [item, {"type": "function_call_output",
                           "call_id": item.call_id,
                           "output": json.dumps(res)}]
```

```python skip
import anthropic

cliente = anthropic.Anthropic()         # ANTHROPIC_API_KEY del entorno
herramientas = [{"name": n, "description": HERRAMIENTAS[n][2],
                 "input_schema": HERRAMIENTAS[n][1].model_json_schema()}
                for n in BASICAS]
mensajes = [{"role": "user", "content": TAREAS[0]}]
r = cliente.messages.create(model="claude-opus-5-5", max_tokens=1024,
                            tools=herramientas, messages=mensajes)
if r.stop_reason == "tool_use":
    resultados = [{"type": "tool_result", "tool_use_id": b.id,
                   "content": json.dumps(ejecutar(b.name, b.input))}
                  for b in r.content if b.type == "tool_use"]
    mensajes += [{"role": "assistant", "content": r.content},
                 {"role": "user", "content": resultados}]
```

Para ejecutar ese mismo código sin red, los clientes oficiales se apuntan a **simuladores locales**: un
`MockTransport` (de `httpx` para el SDK de OpenAI y de `httpx2` para el de Anthropic 2.x) que responde con la forma
documentada. Detrás del simulador no hay un modelo sino un **guion**: una política fija que decide la siguiente
acción mirando cuántos resultados de herramientas hay ya en la conversación. Así se pueden provocar a voluntad los
fallos típicos y comprobar que el bucle los contiene:

| Escenario | Qué hace el «modelo» simulado |
|---|---|
| `correcto` | Pide los dos precios a la vez, después la calculadora y responde con el porcentaje calculado |
| `herramienta_equivocada` | Busca noticias en lugar de precios y responde sin cifra |
| `argumentos_inventados` | Pide el precio de «BUSMAR» con fecha «15/03/2024»; tras el error de validación, corrige y sigue como el correcto |
| `bucle` | Pide una y otra vez el mismo precio, aunque el bucle le avisa de que ya lo tiene |

[^1]: OpenAI, «Function calling» (Responses API), y Anthropic, «Tool use with Claude», consultadas el 2026-10-23.
Las dos documentaciones advierten, cada una a su manera, de que el modelo puede pedir varias llamadas en un mismo
turno; OpenAI permite desactivarlo con `parallel_tool_calls` y Anthropic con `disable_parallel_tool_use`.

```python
import anthropic
import httpx
import httpx2
import openai
import tiktoken

PREGUNTA = TAREAS[0]
DESDE, HASTA = "2024-03-15", "2024-03-22"


def guion(escenario, resultados):
    """Siguiente acción del «modelo» simulado: lista de llamadas
    (nombre, args) o el texto final. `resultados` = salidas de
    herramientas recibidas hasta ahora (diccionarios)."""
    n = len(resultados)
    if escenario == "bucle":
        return [("precio", {"ticker": "BUSI", "fecha": DESDE})]
    if escenario == "herramienta_equivocada":
        if n == 0:
            return [("buscar_noticias", {"ticker": "BUSI",
                     "desde": DESDE, "hasta": HASTA})]
        return "BUSI subió por la oferta de compra de un fondo."
    if escenario == "argumentos_inventados":
        if n == 0:
            return [("precio", {"ticker": "BUSMAR",
                                "fecha": "15/03/2024"})]
        resultados = resultados[1:]
        n -= 1
    if n == 0:
        return [("precio", {"ticker": "BUSI", "fecha": DESDE}),
                ("precio", {"ticker": "BUSI", "fecha": HASTA})]
    if n == 2:
        a, b = (r["resultado"]["cierre"] for r in resultados[:2])
        return [("calculadora", {"expresion": f"({b}/{a} - 1)*100"})]
    v = resultados[2]["resultado"]
    return f"El cierre de BUSI varió un {v:.2f} % entre {DESDE} y {HASTA}."


ENC = tiktoken.get_encoding("o200k_base")
REGISTRO = []                      # tokens de cada petición recibida


def sim_openai(request):
    cuerpo = json.loads(request.content)
    REGISTRO.append(len(ENC.encode(request.content.decode())))
    res = [json.loads(x["output"]) for x in cuerpo["input"]
           if isinstance(x, dict) and x.get("type") == "function_call_output"]
    accion = guion(cuerpo["model"], res)
    if isinstance(accion, str):
        salida = [{"type": "message", "id": "msg_1", "role": "assistant",
                   "status": "completed", "content": [
                       {"type": "output_text", "text": accion,
                        "annotations": []}]}]
    else:
        salida = [{"type": "function_call", "id": f"fc_{len(res)}_{i}",
                   "call_id": f"call_{len(res)}_{i}", "name": nom,
                   "arguments": json.dumps(args), "status": "completed"}
                  for i, (nom, args) in enumerate(accion)]
    return httpx.Response(200, json={
        "id": "resp_sim", "object": "response", "created_at": 0,
        "status": "completed", "model": cuerpo["model"],
        "output": salida, "parallel_tool_calls": True,
        "tool_choice": "auto", "tools": []})


def sim_anthropic(request):
    cuerpo = json.loads(request.content)
    REGISTRO.append(len(ENC.encode(request.content.decode())))
    res = [json.loads(b["content"]) for m in cuerpo["messages"]
           if m["role"] == "user" and isinstance(m["content"], list)
           for b in m["content"] if b.get("type") == "tool_result"]
    accion = guion(cuerpo["model"], res)
    if isinstance(accion, str):
        contenido, parada = [{"type": "text", "text": accion}], "end_turn"
    else:
        contenido = [{"type": "tool_use", "id": f"toolu_{len(res)}_{i}",
                      "name": nom, "input": args}
                     for i, (nom, args) in enumerate(accion)]
        parada = "tool_use"
    return httpx2.Response(200, json={
        "id": "msg_sim", "type": "message", "role": "assistant",
        "model": cuerpo["model"], "content": contenido,
        "stop_reason": parada, "stop_sequence": None,
        "usage": {"input_tokens": 0, "output_tokens": 0}})


oai = openai.OpenAI(
    api_key="sk-simulado", base_url="http://simulado/v1",
    http_client=httpx.Client(transport=httpx.MockTransport(sim_openai)))
ant = anthropic.Anthropic(
    api_key="sk-simulado", base_url="http://simulado",
    http_client=httpx2.Client(
        transport=httpx2.MockTransport(sim_anthropic)))
r = oai.responses.create(model="correcto", input=PREGUNTA)
print([(o.type, o.name, o.arguments) for o in r.output])
m = ant.messages.create(model="correcto", max_tokens=200,
                        messages=[{"role": "user", "content": PREGUNTA}])
print(m.stop_reason, [(b.type, b.name, b.input) for b in m.content])
```
```output
[('function_call', 'precio', '{"ticker": "BUSI", "fecha": "2024-03-15"}'), ('function_call', 'precio', '{"ticker": "BUSI", "fecha": "2024-03-22"}')]
tool_use [('tool_use', 'precio', {'ticker': 'BUSI', 'fecha': '2024-03-15'}), ('tool_use', 'precio', {'ticker': 'BUSI', 'fecha': '2024-03-22'})]
```

El bucle es uno solo, con un pequeño adaptador por proveedor: cómo se piden las llamadas, cómo se leen y cómo se
devuelven los resultados. Los guardarraíles son los mismos del agente local, más un límite de tokens enviados.

```python
def ejecutar_seguro(nombre, args, vistos):
    clave = json.dumps([nombre, args], sort_keys=True)
    if clave in vistos:
        return {"error": "llamada repetida: ya tienes el resultado"}
    vistos.add(clave)
    return ejecutar(nombre, args)


def agente_api(proveedor, escenario, max_pasos=6):
    REGISTRO.clear()
    historial = [{"role": "user", "content": PREGUNTA}]
    vistos, eventos = set(), []
    for paso in range(1, max_pasos + 1):
        if proveedor == "openai":
            r = oai.responses.create(model=escenario, input=historial)
            calls = [(o.call_id, o.name, json.loads(o.arguments))
                     for o in r.output if o.type == "function_call"]
            final = r.output_text
            historial += [o.model_dump(exclude_none=True)
                          for o in r.output]
        else:
            r = ant.messages.create(model=escenario, max_tokens=500,
                                    messages=historial)
            calls = [(b.id, b.name, b.input) for b in r.content
                     if b.type == "tool_use"]
            final = "".join(b.text for b in r.content
                            if b.type == "text")
            historial.append({"role": "assistant", "content": [
                b.model_dump(exclude_none=True) for b in r.content]})
        if not calls:
            eventos.append((paso, "respuesta"))
            return final, eventos, list(REGISTRO)
        bloques = []
        for cid, nombre, args in calls:
            res = ejecutar_seguro(nombre, args, vistos)
            tipo = "error" if "error" in res else "ok"
            eventos.append((paso, f"{nombre}:{tipo}"))
            if proveedor == "openai":
                historial.append({"type": "function_call_output",
                                  "call_id": cid,
                                  "output": json.dumps(res)})
            else:
                bloques.append({"type": "tool_result",
                                "tool_use_id": cid,
                                "content": json.dumps(res),
                                "is_error": "error" in res})
        if bloques:
            historial.append({"role": "user", "content": bloques})
    eventos.append((max_pasos, "corte"))
    return None, eventos, list(REGISTRO)


ESCENARIOS = ["correcto", "herramienta_equivocada",
              "argumentos_inventados", "bucle"]
for esc in ESCENARIOS:
    for prov in ("openai", "anthropic"):
        final, ev, tokens = agente_api(prov, esc)
        print(f"{esc:22s} {prov:9s} pasos={ev[-1][0]}"
              f" llamadas={sum(':' in e for _, e in ev)}"
              f" errores={sum(e.endswith('error') for _, e in ev)}"
              f" tokens={sum(tokens):5d} | {final}")
```
```output
correcto               openai    pasos=3 llamadas=3 errores=0 tokens=  630 | El cierre de BUSI varió un 6.45 % entre 2024-03-15 y 2024-03-22.
correcto               anthropic pasos=3 llamadas=3 errores=0 tokens=  643 | El cierre de BUSI varió un 6.45 % entre 2024-03-15 y 2024-03-22.
herramienta_equivocada openai    pasos=2 llamadas=1 errores=0 tokens=  221 | BUSI subió por la oferta de compra de un fondo.
herramienta_equivocada anthropic pasos=2 llamadas=1 errores=0 tokens=  235 | BUSI subió por la oferta de compra de un fondo.
argumentos_inventados  openai    pasos=4 llamadas=4 errores=1 tokens= 1065 | El cierre de BUSI varió un 6.45 % entre 2024-03-15 y 2024-03-22.
argumentos_inventados  anthropic pasos=4 llamadas=4 errores=1 tokens= 1101 | El cierre de BUSI varió un 6.45 % entre 2024-03-15 y 2024-03-22.
bucle                  openai    pasos=6 llamadas=6 errores=5 tokens= 1643 | None
bucle                  anthropic pasos=6 llamadas=6 errores=5 tokens= 1763 | None
```

Los dos proveedores dan exactamente la misma traza, porque el guion es el mismo y el bucle solo cambia en la forma de
los mensajes. Por escenarios:

- **Correcto**: tres peticiones (dos precios en paralelo, la calculadora, la respuesta) y un 6,45 % calculado.
- **Herramienta equivocada**: busca noticias, recibe el titular de la oferta y responde algo plausible («subió por la
  oferta de compra de un fondo») que no contesta a la pregunta: no hay porcentaje. El bucle no tiene forma de saberlo;
  una regla más del verificador (si la pregunta pide un porcentaje, la respuesta tiene que darlo, calculado) lo
  marcaría.
- **Argumentos inventados**: el ticker «BUSMAR» y la fecha «15/03/2024» no pasan la validación; el error vuelve como
  observación, el «modelo» corrige y el agente termina bien en cuatro pasos. Un modelo capaz suele hacer esto; uno
  pequeño, no siempre.
- **Bucle**: la primera llamada funciona y las cinco siguientes son repeticiones que el bucle no ejecuta. El límite de
  seis pasos lo corta sin respuesta, que es el resultado correcto: mejor nada que algo sin verificar.

```python
final, ev, tokens_bucle = agente_api("anthropic", "bucle", max_pasos=30)
_, _, tokens_ok = agente_api("anthropic", "correcto")
acum = np.cumsum(tokens_bucle)
print("tokens por petición, pasos 1-6:", tokens_bucle[:6])
print("pasos 25-30:", tokens_bucle[24:30])
print("acumulado tras 6, 12 y 30 pasos:", acum[5], acum[11], acum[29])
print("caso correcto, total:", sum(tokens_ok), "en", len(tokens_ok),
      "peticiones")
```
```output
tokens por petición, pasos 1-6: [53, 160, 251, 342, 433, 524]
pasos 25-30: [2253, 2344, 2435, 2526, 2617, 2708]
acumulado tras 6, 12 y 30 pasos: 1763 6818 41639
caso correcto, total: 643 en 3 peticiones
```

*Fig. — Tokens enviados al simulador de la API de Anthropic, acumulados por paso, en el escenario de bucle sin límite de pasos: cada petición reenvía toda la conversación.* Curva convexa de 0 a 30 pasos. Con el límite de 6 pasos (línea destacada) se han enviado 1.763 tokens; con 12, 6.818; con 30, 41.639. El caso correcto termina en 3 pasos con 643 tokens.

Cada petición reenvía la conversación entera, así que lo que se envía por paso crece linealmente: 53 tokens la
primera, 524 la sexta, 2.708 la trigésima. El total crece con el cuadrado de los pasos: 1.763 tokens con el límite de
seis, 6.818 con doce y 41.639 con treinta, frente a 643 del caso correcto. En una API que cobra por token, un agente
sin límite de pasos es un gasto sin techo; con un modelo local, minutos de CPU. Por eso el límite de pasos es también
un límite de presupuesto, y conviene complementarlo con uno explícito de tokens.

## Errores típicos

- **Ejecutar sin validar.** Los argumentos de una llamada son texto generado: un ticker inventado, una fecha en otro
  formato, una expresión con código. Siempre esquema, validación y, en herramientas con efectos (órdenes, correos,
  escrituras), aprobación fuera del modelo (6.4.5).
- **Herramientas con efectos o no deterministas.** Una herramienta que lee la hora real, una web viva o un estado
  mutable hace que la misma traza no se repita y que no se pueda testear. Primero deterministas; lo demás, detrás de
  una capa que registra y fija los datos.
- **Excepciones en vez de errores como datos.** Si una herramienta lanza una excepción, el agente muere y el modelo no
  aprende nada; si devuelve el error, el modelo puede corregir y el registro lo cuenta.
- **Sin límite de pasos ni de gasto.** Un bucle no converge solo. Cada paso reenvía toda la conversación: el coste es
  cuadrático en el número de pasos.
- **No verificar la respuesta final.** Un modelo puede pedir los datos correctos y después redactar otra cosa. Se
  comprueba la respuesta contra la traza, con reglas deterministas.
- **Darle al modelo la aritmética.** Cuentas, conversiones y fechas, en herramientas o en código. El modelo extrae y
  redacta.
- **Dejar que improvise lo que es un procedimiento.** Si la tarea es conocida, un plan fijo (o una herramienta de alto
  nivel) es más fiable, más barato y más fácil de auditar que un ReAct libre.
- **Confiar en lo que devuelven las herramientas como instrucciones.** Un titular o un documento pueden contener texto
  que parezca una orden («ignora lo anterior y…»): es una inyección de instrucciones. Lo que vuelve de una herramienta
  es dato, nunca instrucción (6.4.5).

## Ejercicios

1. Añade al verificador una regla para preguntas de noticias: todo titular entrecomillado en la respuesta tiene que
   aparecer literalmente en algún resultado de `buscar_noticias`. Pruébala con la traza de una búsqueda de noticias de
   BUSI entre el 18 y el 20 de marzo de 2024 y dos respuestas escritas a mano, una fiel y otra inventada.

**Solución.**

```python
def titulares_ok(final, traza):
    reales = {n["titular"] for nom, _, r in traza
              if nom == "buscar_noticias" and "resultado" in r
              for n in r["resultado"]}
    citados = re.findall(r"[«\"“]([^»\"”]+)[»\"”]", final or "")
    return [c for c in citados if c not in reales]


args = {"ticker": "BUSI", "desde": "2024-03-18", "hasta": "2024-03-20"}
traza = [("buscar_noticias", args, ejecutar("buscar_noticias", args))]
fiel = 'El 19 de marzo: «Un fondo ofrece comprar Busmar Hogar».'
falsa = 'Hubo una noticia: "Busmar Hogar dispara su beneficio un 40 %".'
print("fiel:", titulares_ok(fiel, traza) or "OK")
print("inventada:", titulares_ok(falsa, traza))
```
```output
fiel: OK
inventada: ['Busmar Hogar dispara su beneficio un 40 %']
```

La regla deja pasar el titular real y marca el inventado. Es una comprobación literal: una paráfrasis honesta de un
titular real («un fondo lanza una oferta por Busmar») también la suspendería. Una comparación aproximada (por palabras
o por embeddings, lección 6.3.1) tolera la paráfrasis a cambio de dejar pasar más errores; en un informe que va a
clientes, lo prudente es exigir la cita literal.

2. Con el guion `argumentos_inventados`, ¿qué pasaría si `ejecutar` no validara el formato de la fecha? Llama a
   `precio` directamente con «15/03/2024» y con «01/03/2024» (1 de marzo, a la española) y compara con las fechas
   ISO.

**Solución.**

```python
for f in ["15/03/2024", "2024-03-15", "01/03/2024", "2024-03-01"]:
    print(f, precio("BUSI", f))
```
```output
15/03/2024 {'ticker': 'BUSI', 'fecha': '2024-03-15', 'cierre': 26.19}
2024-03-15 {'ticker': 'BUSI', 'fecha': '2024-03-15', 'cierre': 26.19}
01/03/2024 {'ticker': 'BUSI', 'fecha': '2024-01-03', 'cierre': 27.75}
2024-03-01 {'ticker': 'BUSI', 'fecha': '2024-03-01', 'cierre': 25.2}
```

Sin validación, «15/03/2024» funciona por casualidad: pandas reconoce el formato y lo interpreta bien. Pero
«01/03/2024», el 1 de marzo a la española, se lee con el mes primero como 3 de enero, y la herramienta devuelve sin
ningún error el cierre de otra fecha (27,75 en lugar de 25,20). Un fallo silencioso es peor que uno ruidoso: por eso
el esquema exige AAAA-MM-DD, la validación rechaza lo demás y el resultado dice qué fecha se usó.

3. Diseña, sin código, las herramientas de un agente que prepara un resumen diario de una cartera: qué herramientas
   le darías, cuáles no, y qué haría el código en lugar del modelo.

**Solución.**

Herramientas de solo lectura, deterministas para una fecha dada: posiciones de la cartera a la fecha, cierres y
rentabilidades del día (calculadas en código), noticias del día por ticker con la hora de publicación (para separar
lo de antes y lo de después del cierre), y eventos del calendario (resultados, dividendos). Todas con la fecha como
argumento obligatorio, para poder reproducir el informe de cualquier día y para no mezclar información posterior. No
le daría nada que escriba o envíe órdenes: el agente redacta, no opera (6.4.5). El código haría las cifras (P&L por
posición, contribuciones, variaciones), la selección de qué entra en el informe (umbrales de movimiento, materialidad)
y la verificación final: toda cifra del texto en la traza, todo titular citado existente. El modelo solo redacta el
resumen a partir de una tabla ya calculada y, como mucho, elige qué noticias explicar. Con ese diseño, un modelo
pequeño puede hacer el trabajo; con un ReAct libre sobre herramientas de bajo nivel, ni uno grande es fiable cada día.

## Autoevaluación

1. En una API con tool calling, ¿quién ejecuta la función `precio(ticker, fecha)`?

   - A. El modelo, en los servidores del proveedor
   - B. El programa que llama a la API: el modelo solo devuelve el nombre de la herramienta y los argumentos en JSON, y espera el resultado en el siguiente mensaje
   - C. Nadie: el modelo se inventa el resultado
   - D. El navegador del usuario

   *Respuesta B: El programa que llama a la API: el modelo solo devuelve el nombre de la herramienta y los argumentos en JSON, y espera el resultado en el siguiente mensaje.* En las herramientas definidas por el cliente, la respuesta del modelo trae un bloque `tool_use` (Anthropic) o un elemento `function_call` (OpenAI) con nombre y argumentos. El programa la ejecuta y devuelve un `tool_result` o un `function_call_output`. Por eso el programa puede validar, limitar y auditar cada llamada.

2. El LLM local pide los dos cierres correctamente (26,19 y 27,88) y responde que el cambio «fue de 27.88%». ¿Qué guardarraíl lo detecta?

   - A. El límite de pasos
   - B. La validación Pydantic de los argumentos
   - C. La detección de llamadas repetidas
   - D. Un verificador de la respuesta final que exige que todo porcentaje salga de una llamada a la calculadora (o a una herramienta que calcula) y toda cifra, de algún resultado

   *Respuesta D: Un verificador de la respuesta final que exige que todo porcentaje salga de una llamada a la calculadora (o a una herramienta que calcula) y toda cifra, de algún resultado.* Los argumentos eran válidos y no hubo bucle: el error está en la respuesta final, que copia un cierre como si fuera el porcentaje. Solo una comprobación de la respuesta contra la traza de herramientas lo detecta.

3. ¿Para qué sirve devolver al modelo un error de validación («fecha: String should match pattern…») en lugar de cortar el agente?

   - A. Para que el modelo pueda corregir los argumentos en el paso siguiente, como hace el escenario simulado de argumentos inventados
   - B. Para gastar menos tokens
   - C. Porque las APIs no permiten cortar un agente
   - D. No sirve de nada: el modelo nunca corrige

   *Respuesta A: Para que el modelo pueda corregir los argumentos en el paso siguiente, como hace el escenario simulado de argumentos inventados.* Un error informativo es una observación más del bucle ReAct. Los modelos capaces suelen corregir el formato al verlo; uno pequeño quizá no, y por eso el error se cuenta y hay un límite de pasos. Lo que no se hace nunca es ejecutar la herramienta con argumentos no validados.

4. En el escenario de bucle, sin límite de pasos, el agente repite la misma llamada. ¿Por qué el coste crece más deprisa que el número de pasos?

   - A. Porque cada llamada repetida cuesta el doble
   - B. Porque la herramienta tarda más cada vez
   - C. Porque cada petición reenvía toda la conversación anterior: los tokens enviados por paso crecen linealmente y el total, de forma cuadrática
   - D. No crece: el coste es constante

   *Respuesta C: Porque cada petición reenvía toda la conversación anterior: los tokens enviados por paso crecen linealmente y el total, de forma cuadrática.* Las APIs son sin estado: cada paso envía el historial completo, con todas las llamadas y resultados previos. Con 30 pasos se envían muchas veces más tokens que con 6. El límite de pasos y la detección de repeticiones son también un límite de gasto.

5. Para «¿cuánto varió en % el cierre de BUSI entre dos fechas?», ¿qué diseño es más fiable con un modelo pequeño?

   - A. Darle solo una calculadora y confiar en que haga la cuenta
   - B. Darle una herramienta `variacion(ticker, desde, hasta)` que hace el cálculo en código, o un plan fijo en el que el modelo solo extrae los parámetros
   - C. Pedirle que piense paso a paso sin herramientas
   - D. Usar temperatura alta para que explore

   *Respuesta B: Darle una herramienta `variacion(ticker, desde, hasta)` que hace el cálculo en código, o un plan fijo en el que el modelo solo extrae los parámetros.* Cuanto menos tenga que encadenar el modelo, menos puntos de fallo. Una herramienta de más alto nivel convierte tres llamadas y una cuenta en una, y la aritmética la hace el código. Es planificación fijada por el programador para una tarea conocida.

## Ideas clave

- Tool calling: el modelo emite peticiones estructuradas (nombre y argumentos JSON); el programa las valida, las ejecuta y devuelve el resultado. Nada se ejecuta sin pasar por el código.
- ReAct alterna razonamiento, acción (llamada a herramienta) y observación (resultado) hasta responder; planificar primero y ejecutar después es la alternativa cuando la tarea es conocida.
- Guardarraíles mínimos del bucle: validación de argumentos con esquema, errores devueltos como observación, detección de repeticiones, límite de pasos y verificación de la respuesta final contra la traza.
- El LLM local de 0,5B formula bien llamadas sencillas, pero ignora errores y resultados al redactar: inventa un precio para un ticker inexistente y confunde un cierre con un porcentaje.
- Las formas de OpenAI (`function_call` y `function_call_output`) y de Anthropic (`tool_use` y `tool_result`) son equivalentes; el mismo bucle sirve para las dos con un adaptador, y se prueba sin red contra simuladores.

## Recursos

### Research

- [Yao et al. (2022): ReAct, Synergizing Reasoning and Acting in Language Models (arXiv:2210.03629)](https://arxiv.org/abs/2210.03629) · arxiv.org
- [Schick et al. (2023): Toolformer, Language Models Can Teach Themselves to Use Tools (arXiv:2302.04761)](https://arxiv.org/abs/2302.04761) · arxiv.org

### Documentación

- [Anthropic: tool use con Claude (tool_use, tool_result)](https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview) · platform.claude.com
- [OpenAI: function calling (Responses API)](https://developers.openai.com/api/docs/guides/function-calling) · developers.openai.com
- [Hugging Face Transformers: herramientas en las plantillas de chat](https://huggingface.co/docs/transformers/main/en/chat_extras) · huggingface.co
- [Qwen: function calling (plantilla de herramientas tipo Hermes y sus límites)](https://qwen.readthedocs.io/en/latest/framework/function_call.html) · qwen.readthedocs.io

### Datos

- [Datos del nivel 6: corpus de noticias (Parquet)](https://pythonparatrading.com/curso/datos/nivel-6/noticias6_corpus.parquet) · Descarga · noticias6_corpus.parquet

Lección anterior: [6.3.3 · RAG sobre informes, folletos y KID, y cómo evaluarlo](https://pythonparatrading.com/curso-python-trading/nivel-6/rag-sobre-informes-y-su-evaluacion/)

Lección siguiente: [6.4.2 · Marcos de agentes y MCP](https://pythonparatrading.com/curso-python-trading/nivel-6/langgraph-llamaindex-sdks-y-mcp/)
