# El agent loop en acción: contexto, herramientas y errores

> Diseca el agent loop de Strands Agents: contexto, tu primera herramienta contra SQLite, errores que gestiona el modelo y el límite de tokens por respuesta.

- Capítulo: 1.3
- Dificultad: intermedio
- Fuente: https://book-ai-engineer.lucenyo.dev/es/lessons/agents/agent-loop-herramientas-strands-agents/

---
En el capítulo anterior tu agente respondió a su primera pregunta, pero fue una caja negra: le hablaste, te contestó, y no viste nada de lo que pasó por dentro. En este capítulo abrimos esa caja. Al terminarlo serás capaz de seis cosas:

1. Explicar el agent loop completo: modelo, herramientas, contexto y *stop reasons*.
2. Entender qué es el contexto y por qué las llamadas a un LLM son *stateless*.
3. Crear la primera herramienta real del proyecto: conectada a una base de datos.
4. Leer el historial interno del agente (`agent.messages`) para ver cómo se acumula el contexto.
5. Comprobar que un error no rompe la aplicación, sino que hace más inteligente al agente.
6. Poner un límite a la cantidad de tokens que el agente puede usar en una respuesta.

---

## Por qué el contexto existe: las llamadas son stateless

Cada solicitud que le haces a un LLM —Bedrock, OpenAI, Gemini, da igual el proveedor— es **stateless**: completamente independiente de las anteriores. Si le preguntas "¿qué es Python?" y el modelo te da una explicación completa, y en la siguiente llamada le dices simplemente "resúmelo", el modelo no tiene ni idea de a qué te refieres. No recuerda la pregunta anterior porque, para él, cada llamada es la primera vez que te ve.

El **contexto** es toda la información que se envía al modelo en cada solicitud para que entienda qué está ocurriendo y genere una respuesta coherente: los mensajes del usuario, las respuestas previas del modelo, las llamadas a herramientas y sus resultados, y el system prompt.

Si quieres que el modelo recuerde una conversación, ese historial completo tiene que viajar de nuevo en cada solicitud. Eso es exactamente lo que vimos en el diagrama de acumulación de contexto del [capítulo anterior](/es/lessons/agents/introduccion-strands-agents/): con cada iteración del agent loop, el contexto crece un poco más, y es precisamente ese historial creciente el que le permite al modelo mantener la continuidad entre una pregunta y la siguiente.

Ese crecimiento no es infinito. Cada modelo tiene un **context window**: la cantidad máxima de información que puede procesar en una sola solicitud. Si el historial la supera, hay que reducirlo —recortando o resumiendo mensajes antiguos— o el modelo empieza a perder detalles y a responder de forma inconsistente. Strands automatiza parte de esto con un componente llamado *conversation manager*, que veremos en detalle en un capítulo dedicado a la memoria. Por ahora basta con saber que existe.

---

## Los parámetros de cada solicitud

Además del contexto, cada llamada al modelo acepta parámetros que ajustan su comportamiento:

| Parámetro | Para qué sirve |
| :--- | :--- |
| `model_id` | Qué modelo concreto responde |
| `system_prompt` | La personalidad y el rol del agente |
| `tools` | El listado de herramientas disponibles en esa interacción |
| `max_tokens` | El límite de tokens que el modelo puede generar en su respuesta |
| `temperature` | Cuánta creatividad tiene el modelo al responder |

De todos estos, el que hace posible el agent loop tal y como lo conocemos son las **herramientas**. Sin ellas, el modelo solo puede generar texto a partir de lo que ya sabe; con ellas, puede pedir que se consulte una base de datos, se ejecute código o se llame a una API antes de responder.

---

## De vuelta a la cárcel: por qué el modelo no ejecuta nada

Como vimos en el capítulo anterior, un LLM vive encerrado: no puede consultar nada por sí mismo. Cuando arrancas una conversación, le envías —además del contexto— un listado de herramientas disponibles, normalmente como un esquema en formato JSON: qué hace cada una, qué argumentos acepta y cómo se invoca.

Cuando el modelo decide que necesita una herramienta, **no la ejecuta**: no puede. En su lugar, responde con una solicitud de ejecución —un *tool call*— indicando el nombre de la herramienta y los argumentos, siguiendo exactamente el esquema que recibió. Tu aplicación —en este caso, Strands— recibe esa solicitud, ejecuta la función real, y añade el resultado al contexto antes de volver a llamar al modelo. El modelo, ya con esa información nueva, decide si responde o si necesita otra herramienta más.

### Los stop reasons

¿Cómo sabe la aplicación si debe seguir iterando o si ya puede entregarle la respuesta al usuario? Cada vez que el modelo responde, indica también un **stop reason**: el motivo por el que terminó de generar.

| Stop reason | Qué significa | Qué hace el agent loop |
| :--- | :--- | :--- |
| `tool_use` | El modelo quiere ejecutar una herramienta | Strands la ejecuta, añade el resultado al contexto y vuelve a llamar al modelo |
| `end_turn` | El modelo terminó de razonar y tiene la respuesta final | El bucle se detiene y esa respuesta llega al usuario |
| `max_tokens` | El modelo alcanzó el límite de tokens de salida antes de terminar | La respuesta puede quedar incompleta, cortada a mitad de frase |

Existen otros *stop reasons* más específicos —relacionados con guardrails o filtros de contenido— que iremos viendo más adelante. De momento, con `tool_use` y `end_turn` ya puedes seguir el recorrido completo de una solicitud: el modelo pide una herramienta, la aplicación la ejecuta, y así hasta que el modelo decide que ya puede responder.

---

## Manos a la obra: una herramienta contra una base de datos real

Vamos a construir la primera herramienta de verdad de tu asistente de gimnasio: una que consulte las rutinas de entrenamiento disponibles. Para mantener responsabilidades separadas, el proyecto va a tener tres archivos:

- `crear_db.py` — un script de una sola ejecución que crea la base de datos con algunas rutinas de ejemplo.
- `db.py` — la capa de acceso a esa base de datos: abrir conexión, consultar, insertar.
- `herramientas.py` — las herramientas de tu agente, entre ellas la que consulta rutinas.
- `main.py` — el punto de ensamblaje: crea el agente, registra las herramientas y lo ejecuta.

Usamos **SQLite** porque viene integrado en Python —no hay que instalar nada— y es más que suficiente para desarrollo local. El día que quieras una base de datos real en producción, solo cambias la cadena de conexión en `db.py`; el resto de la lógica funciona igual.

### Crear la base de datos

```python
# crear_db.py
import sqlite3

conexion = sqlite3.connect("gimnasio.db")
cursor = conexion.cursor()

cursor.execute("""
    CREATE TABLE IF NOT EXISTS rutinas (
        rutina_id TEXT PRIMARY KEY,
        nombre TEXT NOT NULL,
        nivel TEXT NOT NULL,
        duracion_semanas INTEGER NOT NULL
    )
""")

cursor.executemany(
    "INSERT OR REPLACE INTO rutinas VALUES (?, ?, ?, ?)",
    [
        ("R001", "Fuerza full body", "principiante", 6),
        ("R002", "Hipertrofia push/pull/legs", "intermedio", 8),
        ("R003", "Powerlifting 5/3/1", "avanzado", 12),
    ],
)

conexion.commit()
conexion.close()
```

Ejecútalo una sola vez:

```bash
uv run crear_db.py
```

Esto crea `gimnasio.db` con una tabla `rutinas` y tres registros. Una vez ejecutado, este script ya cumplió su función — puedes borrarlo si quieres, no forma parte de la aplicación en marcha.

### La capa de acceso a datos

```python
# db.py
import os
import sqlite3
from contextlib import contextmanager

NOMBRE_DB = os.environ.get("GIMNASIO_DB", "gimnasio.db")

@contextmanager
def _conexion():
    """Abre una conexión con acceso a las columnas por nombre y la cierra siempre al salir."""
    conexion = sqlite3.connect(NOMBRE_DB)
    conexion.row_factory = sqlite3.Row
    try:
        yield conexion
    finally:
        conexion.close()

def obtener_uno(sql: str, parametros: tuple = ()) -> dict | None:
    """Ejecuta un SELECT y devuelve la primera fila como diccionario, o None si no hay resultados."""
    with _conexion() as conexion:
        fila = conexion.execute(sql, parametros).fetchone()
        return dict(fila) if fila else None

def obtener_todos(sql: str, parametros: tuple = ()) -> list[dict]:
    """Ejecuta un SELECT y devuelve todas las filas como una lista de diccionarios."""
    with _conexion() as conexion:
        filas = conexion.execute(sql, parametros).fetchall()
        return [dict(fila) for fila in filas]

def ejecutar(sql: str, parametros: tuple = ()) -> int:
    """Ejecuta un INSERT, UPDATE o DELETE y devuelve el número de filas afectadas."""
    with _conexion() as conexion:
        cursor = conexion.execute(sql, parametros)
        conexion.commit()
        return cursor.rowcount
```

El `contextmanager` se encarga de que la conexión se cierre siempre, incluso si algo falla a mitad de la consulta — algo que va a importar todavía más cuando lleguemos a multiagente. `row_factory = sqlite3.Row` hace que cada fila se pueda leer por nombre de columna en lugar de por índice, que es justo lo que necesitamos para convertirla en un diccionario.

### La herramienta

```python
# herramientas.py
from strands import tool

import db

@tool
def consultar_rutina(rutina_id: str) -> str:
    """Consulta la información de una rutina de entrenamiento por su ID.

    Args:
        rutina_id: Identificador de la rutina, por ejemplo 'R001'.
    """
    rutina = db.obtener_uno(
        "SELECT nombre, nivel, duracion_semanas FROM rutinas WHERE rutina_id = ?",
        (rutina_id,),
    )

    if rutina is None:
        disponibles = db.obtener_todos("SELECT rutina_id FROM rutinas")
        ids = ", ".join(fila["rutina_id"] for fila in disponibles)
        return f"No existe la rutina con ID '{rutina_id}'. Las rutinas disponibles son: {ids}."

    return (
        f"Rutina: {rutina['nombre']}. "
        f"Nivel: {rutina['nivel']}. "
        f"Duración: {rutina['duracion_semanas']} semanas."
    )
```

El *docstring* y los *type hints* de una función decorada con `@tool` no son notas para otro desarrollador: son el material con el que Strands construye el esquema JSON que se envía al modelo. Una herramienta mal documentada es una herramienta que el modelo va a usar mal — o que directamente no va a usar cuando debería.

Fíjate en el `if rutina is None`: en lugar de lanzar una excepción o cortar la ejecución, la herramienta devuelve un mensaje en texto claro con las opciones válidas. Esa decisión es la que le da al modelo material para recuperarse del error, y la vamos a ver en detalle en la sección de manejo de errores.

### Ensamblar el agente

```python
# main.py
from strands import Agent
from strands.models import BedrockModel

from herramientas import consultar_rutina

SYSTEM_PROMPT = """
Eres el asistente virtual de un gimnasio. Atiendes a las personas que
entrenan en español, con un tono claro y cercano.
"""

def crear_modelo() -> BedrockModel:
    return BedrockModel(
        model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0",  # usa un ID de tu listado
        region_name="us-east-2",
        temperature=0.3,
        max_tokens=1024,
    )

agente = Agent(
    model=crear_modelo(),
    system_prompt=SYSTEM_PROMPT,
    tools=[consultar_rutina],
)

respuesta = agente("¿Cuánto dura la rutina R002 y qué nivel tiene?")
print(respuesta)
```

Ejecuta con `uv run main.py`. Lo que ocurre por debajo es el agent loop completo: el modelo recibe la pregunta, se da cuenta de que no conoce esa información, pide ejecutar `consultar_rutina` con `rutina_id="R002"` (*stop reason* `tool_use`), Strands ejecuta la función real contra `gimnasio.db`, añade el resultado al contexto y vuelve a llamar al modelo. Esta vez el modelo ya tiene todo lo necesario, así que responde en lenguaje natural (*stop reason* `end_turn`) y esa respuesta es la que ves en la consola: algo como *"La rutina R002 es de hipertrofia push/pull/legs, nivel intermedio, y dura 8 semanas."*

---

## Cuando el modelo se equivoca: errores que no rompen nada

¿Qué pasa si le preguntas por una rutina que no existe? Cambia la pregunta a algo como `"¿Qué me puedes decir de la rutina R999?"` y vuelve a ejecutar.

El modelo sigue exactamente el mismo camino: pide ejecutar `consultar_rutina` con `rutina_id="R999"`. La herramienta consulta la base de datos, no encuentra nada, y devuelve el mensaje de error con el listado de IDs válidos. Ese mensaje —no una excepción, no un *crash*— se añade al contexto y vuelve al modelo. El modelo lo lee, entiende que el ID no existe, y te responde con algo como: *"No encuentro la rutina R999 en el catálogo. Las que tenemos disponibles son R001, R002 y R003. ¿Quieres que te cuente sobre alguna de ellas?"*

Nadie programó un `if` que dijera "si el ID no existe, sugiere alternativas y pide que el usuario vuelva a intentarlo". Esa lógica no vive en tu código: la infirió el modelo al leer el resultado de la herramienta. Este es exactamente el enfoque model-driven del que hablamos en el capítulo anterior — el control se desplaza del flujo explícito a las herramientas bien diseñadas y a la capacidad del modelo de razonar sobre sus resultados.

Esto es lo que separa un enfoque *workflow-driven* de uno *model-driven*: en el primero, un argumento con el formato equivocado o un identificador inexistente suele tumbar el flujo y exige que el desarrollador anticipe ese caso a mano. Aquí, el error se convierte en información dentro del contexto, y el modelo decide cómo reaccionar — igual que reaccionaría ante un tipo de dato incorrecto en un argumento, o cualquier otro problema que la herramienta le describa en texto claro.

---

## Mirar dentro del contexto: `agent.messages`

Strands guarda el historial completo de la conversación en `agent.messages`. Añade esto al final de `main.py` y vuelve a ejecutar la primera pregunta (la de la rutina R002):

```python
print(agente.messages)
```

Lo que vas a ver —simplificado aquí para que se lea bien— es una lista de mensajes que reconstruye exactamente el recorrido que acabamos de describir:

```python
[
  {
    "role": "user",
    "content": [{"text": "¿Cuánto dura la rutina R002 y qué nivel tiene?"}],
  },
  {
    "role": "assistant",
    "content": [
      {
        "toolUse": {
          "toolUseId": "tooluse_ab12cd34",
          "name": "consultar_rutina",
          "input": {"rutina_id": "R002"},
        }
      }
    ],
  },
  {
    "role": "user",
    "content": [
      {
        "toolResult": {
          "toolUseId": "tooluse_ab12cd34",
          "content": [
            {"text": "Rutina: Hipertrofia push/pull/legs. Nivel: intermedio. Duración: 8 semanas."}
          ],
        }
      }
    ],
  },
  {
    "role": "assistant",
    "content": [
      {"text": "La rutina R002 es de hipertrofia push/pull/legs, nivel intermedio, y dura 8 semanas."}
    ],
  },
]
```

Cuatro mensajes, dos vueltas del agent loop:

1. **`user`** — tu pregunta original, tal cual la escribiste.
2. **`assistant`** — el modelo no responde texto: solicita ejecutar `consultar_rutina` con el argumento que decidió.
3. **`user`** — el resultado de la herramienta, inyectado de vuelta como si lo dijera el usuario. Así es como el resultado entra en el contexto para la siguiente llamada.
4. **`assistant`** — con esa información ya disponible, la respuesta final en lenguaje natural.

Esto es el contexto del que hemos hablado todo el capítulo, hecho concreto: no es un concepto abstracto, es literalmente esta lista de diccionarios que crece con cada iteración y que viaja completa en cada llamada al modelo.

---

## Poner un límite: `max_tokens`

Ya viste el parámetro `max_tokens` en `crear_modelo()`. Fija la cantidad máxima de tokens que el modelo puede generar en **una respuesta**. Tiene dos razones de ser:

- **Coste.** Más tokens de salida es más dinero facturado por cada solicitud. Si tu agente entra en un razonamiento innecesariamente largo, `max_tokens` pone un techo.
- **Previsibilidad.** Sin un límite, una respuesta puede alargarse mucho más de lo que tu aplicación necesita mostrar.

El riesgo del otro lado: si lo fijas demasiado bajo, el modelo puede alcanzar ese límite a mitad de una respuesta —o a mitad de un *tool call*— y el *stop reason* que recibirás será `max_tokens` en lugar de `end_turn`. El resultado es una respuesta cortada, no necesariamente una respuesta completa pero breve. Ajusta este valor pensando en el tipo de respuestas que tu agente necesita dar, no en un número arbitrario.

---

## Resumen

- Las llamadas a un LLM son **stateless**: si quieres que recuerde algo, ese historial —el **contexto**— viaja completo en cada solicitud.
- Cada solicitud acepta parámetros como `model_id`, `system_prompt`, `tools`, `max_tokens` y `temperature`.
- El modelo nunca ejecuta una herramienta directamente: la **solicita** (*tool call*), y es la aplicación —Strands— quien la ejecuta y devuelve el resultado al contexto.
- El **stop reason** de cada respuesta le dice al agent loop si debe seguir iterando (`tool_use`) o si ya puede entregar la respuesta (`end_turn`); si se agotan los tokens de salida, el motivo es `max_tokens`.
- Una herramienta es una función con `@tool`, bien documentada con *docstring* y *type hints* — esa documentación es la que se convierte en el esquema JSON que lee el modelo.
- Cuando una herramienta encuentra un error, lo mejor es devolver un mensaje en texto claro en lugar de lanzar una excepción: el modelo puede razonar sobre ese texto y adaptarse, algo característico del enfoque **model-driven**.
- `agent.messages` expone el historial completo de la conversación: mensajes de usuario, solicitudes de herramienta (`toolUse`), resultados (`toolResult`) y respuestas finales del modelo.
- `max_tokens` limita cuánto puede generar el modelo en una respuesta — importa tanto para el coste como para evitar respuestas cortadas a mitad de frase.

En el siguiente capítulo seguiremos dando forma a tu asistente de gimnasio, sumando más herramientas a su catálogo y viendo cómo se comporta cuando tiene varias entre las que elegir.

---

## Autoevaluación

Antes de continuar, comprueba que los conceptos clave han quedado claros.
