El agent loop en acción: contexto, herramientas y errores
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:
- Explicar el agent loop completo: modelo, herramientas, contexto y stop reasons.
- Entender qué es el contexto y por qué las llamadas a un LLM son stateless.
- Crear la primera herramienta real del proyecto: conectada a una base de datos.
- Leer el historial interno del agente (
agent.messages) para ver cómo se acumula el contexto. - Comprobar que un error no rompe la aplicación, sino que hace más inteligente al agente.
- 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.
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: 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
# 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:
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
# 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
# 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."
)
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
# 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?”
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):
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:
[
{
"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:
user— tu pregunta original, tal cual la escribiste.assistant— el modelo no responde texto: solicita ejecutarconsultar_rutinacon el argumento que decidió.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.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_tokenspone 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_tokensytemperature. - 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 esmax_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.messagesexpone el historial completo de la conversación: mensajes de usuario, solicitudes de herramienta (toolUse), resultados (toolResult) y respuestas finales del modelo.max_tokenslimita 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.
1. ¿Por qué es necesario reenviar el historial completo de la conversación en cada solicitud al modelo?
2. Cuando el modelo decide que necesita usar una herramienta, ¿qué hace exactamente?
3. ¿Qué stop reason indica que el agent loop debe detenerse y entregar la respuesta al usuario?
4. En la herramienta consultar_rutina, ¿para qué se usan el docstring y los type hints de la función?
5. Cuando consultar_rutina recibe un ID que no existe, ¿qué hace y por qué es importante ese diseño?
6. ¿Qué representan los mensajes con role 'user' que contienen un bloque toolResult dentro de agent.messages?