Módulo 1: Por qué operar es distinto de construir
Lo que no puedes ver sin instrumentación
Descripción
La lección 02 nombró, en abstracto, el problema: nadie va a estar mirando cuando el agente corra para usuarios reales. Esta lección lo hace concreto, con código real, y le pone un límite exacto. No es que "sería útil saber más" — es que hay preguntas específicas, nombrables, que no se pueden responder con lo que run_reservo_agent te da hoy, sin importar cuánto te esfuerces en mirar.
Vas a intentarlo de tres formas distintas: usando solo la respuesta final (lo que un sistema real consume), inspeccionando history a mano con print_trace (lo máximo que puedes hacer sin instrumentar nada), y —el caso más revelador— intentando recuperar algo de un run que falló. Las tres formas tienen el mismo límite duro al final.
Conexión con el módulo
Esta es la lección que le pone nombre a la analogía que sostiene todo el módulo. Un auto sin tablero anda —el motor funciona, las ruedas giran— pero el conductor no tiene forma de saber la velocidad, cuánta gasolina queda, ni si el motor se está sobrecalentando, hasta que el auto ya se detuvo solo, en la banquina. run_reservo_agent, tal como está, es exactamente ese auto: funciona, y bien —lo confirmaste en las lecciones 01 y 02—, pero no tiene tablero. Esta lección mide, con precisión, qué tan lejos llega "mirar con atención" antes de que necesites, sí o sí, instrumentar algo.
Analogía: el auto sin tablero
Manejas un auto que arranca sin problema, acelera bien, frena cuando lo pides. Nada te dice que algo anda mal — hasta que, en medio de la autopista, se apaga solo. ¿Se quedó sin gasolina? ¿El motor se sobrecalentó? ¿Una llanta venía perdiendo aire desde hace una hora? No hay forma de saberlo mirando el auto desde afuera, porque nunca tuviste un tablero: ni velocímetro, ni indicador de combustible, ni testigo de temperatura. El auto funcionaba — perfectamente, de hecho, cada una de las veces que lo manejaste antes. Lo que le faltaba no era funcionar mejor. Le faltaba la capacidad de decirte, en cualquier momento, cómo estaba funcionando.
Eso es exactamente run_reservo_agent en este momento del módulo. Cada run que corriste en las lecciones 01 y 02 funcionó correctamente. El problema nunca fue que el agente estuviera mal construido — es que, corra bien o corra mal, no tiene ninguna forma de decírtelo salvo la respuesta final de texto, que es tan informativa sobre "cómo llegó ahí" como un auto silencioso es informativo sobre cuánta gasolina le queda.
Ejemplo trabajado: tres formas de intentar ver adentro, y dónde se detiene cada una
Forma 1: solo la respuesta final (lo que un sistema real usa)
Retoma la tarea canónica, y quédate solo con lo que un servicio real conservaría — la respuesta de texto:
import reservo_agent as ra
model_script_demo = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "list_rooms", "input": {}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "get_quote",
"input": {"room": "Focus", "tier": "premium", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_03", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_04", "name": "book_room",
"input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Reservé Focus pro por 3 horas para Ana. Total $60.00. Confirmación #1."}]},
]
final, history = ra.run_reservo_agent("Reserva Focus pro 3h para Ana", model_script_demo)
print("RESPUESTA:", final["content"][0]["text"])
Qué esperar:
RESPUESTA: Reservé Focus pro por 3 horas para Ana. Total $60.00. Confirmación #1.
Con esto, cero de las cinco preguntas de la lección 01 tienen respuesta. No es que sea difícil extraerlas — es que la variable final no las contiene. final["content"][0]["text"] es un string. No hay ningún lugar de un string donde vivan "cuántos pasos", "qué herramientas" o "cuánto costó".
Forma 2: inspeccionar history a mano, con print_trace
history sí existe, todavía, como variable local — porque no descartamos la segunda mitad de lo que run_reservo_agent devuelve. Usa print_trace, retomado sin cambios de agent-fundamentals M8, para mirar adentro:
ra.print_trace(history)
Qué esperar:
[0] user pregunta: 'Reserva Focus pro 3h para Ana'
[1] assistant tool_use(list_rooms): {}
[2] user tool_result: [{"room": "Focus", "rate_cents": 2500}, {"room": "Studio", "rate_cents": 4000}, {"room": "Boardroom", "rate_cents": 8000}]
[3] assistant tool_use(get_quote): {'room': 'Focus', 'tier': 'premium', 'hours': 3}
[4] user tool_result [is_error]: 'tier'='premium' no está en enum ['basic', 'pro']
[5] assistant tool_use(get_quote): {'room': 'Focus', 'tier': 'pro', 'hours': 3}
[6] user tool_result: {"price_cents": 6000}
[7] assistant tool_use(book_room): {'room': 'Focus', 'tier': 'pro', 'hours': 3, 'member': 'Ana'}
[8] user tool_result: {"booking_id": 1, "confirmed": true}
[9] assistant texto final: 'Reservé Focus pro por 3 horas para Ana. Total $60.00. Confirmación #1.'
Ahora sí puedes responder tres de las cinco preguntas, a mano: pasos (10, contando len(history)), herramientas llamadas y en qué orden (list_rooms, get_quote dos veces, book_room), y si algo falló (sí, el turno [4] trae [is_error]). Confírmalo con código, no solo mirando:
print("pasos totales :", len(history))
print("tool calls totales :", sum(
1 for m in history if m["role"] == "assistant"
and isinstance(m["content"], list) and m["content"][0]["type"] == "tool_use"
))
print("turnos con is_error :", sum(
1 for m in history if m["role"] == "user"
and isinstance(m["content"], list) and any(b.get("is_error") for b in m["content"])
))
Qué esperar:
pasos totales : 10
tool calls totales : 4
turnos con is_error : 1
Tres de cinco. Pero fíjate en el costo de haber llegado hasta aquí: tuviste que saber de antemano que history seguía existiendo, llamar a print_trace explícitamente, y leer diez líneas con tus propios ojos (o escribir tres líneas de conteo tú mismo). Eso funciona para un run, en un notebook, mientras tú decides mirar. No funciona para tres mil runs de la noche del martes, que nadie miró en el momento en que ocurrieron — porque para el momento en que alguien pregunte, ese history específico ya no existe en ningún lado: era una variable local que murió cuando la función retornó y el script terminó.
Forma 3: las dos preguntas que ni el history completo puede responder
Con history todavía en pantalla, busca el costo y la latencia. No hace falta escribir código para confirmar que no están — solo recorre la traza de arriba, campo por campo: hay un role, un type, un name, un input, un content, un is_error cuando corresponde. En ningún bloque hay un campo de tokens, de centavos, ni de milisegundos. history registra qué pasó en cada paso — nunca cuánto costó ni cuánto tardó ese paso. No es una limitación de cómo lo estás leyendo: es que esa información nunca se capturó, porque nada en run_reservo_agent, dispatch_robust, ni ninguna de las funciones que ya construiste en agent-fundamentals mide el tiempo o los tokens de nada. No existe un bug que arreglar aquí — existe una capa entera que todavía no se construyó, y que esta guía construye a partir del Módulo 2 en adelante.
El caso más revelador: cuando el run falla, pierdes hasta lo poco que tenías
Las tres formas de arriba asumen que el run terminó, con éxito o con algún is_error en el camino, y devolvió algo. ¿Qué pasa cuando el run ni siquiera termina — cuando se agota el tope de iteraciones?
stuck_script = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": f"toolu_0{n}", "name": "list_rooms", "input": {}}]}
for n in range(1, 4)
]
try:
ra.run_reservo_agent("Reserva algo", stuck_script, max_iterations=2)
except RuntimeError as exc:
print("RuntimeError capturado:", exc)
print("¿'history' quedó definida en este scope?", "history" in dir())
Qué esperar:
RuntimeError capturado: max_iterations alcanzado (2)
¿'history' quedó definida en este scope? False
Esta es la versión más dura del problema. Cuando run_reservo_agent se agota, messages —la lista que se estaba construyendo paso a paso, adentro de la función— nunca llega a un return. Se pierde por completo junto con el resto del stack frame de la función, en el instante en que se lanza el RuntimeError. Ni siquiera la Forma 2 —inspeccionar history a mano— es posible aquí: no hay ningún history que inspeccionar, porque la función nunca te lo entregó. El auto no solo se apagó sin avisar — se apagó y además te confiscó el tablero (si lo hubiera tenido) en el mismo momento. Esta es, con precisión, una de las razones por las que el Módulo 2 no espera a que un run termine para registrar lo que va pasando: si esperas al final, un run que falla te deja sin ningún registro del todo.
Errores comunes
-
Pensar que "podría inspeccionar
historysi hiciera falta" es lo mismo que tener observabilidad. La Forma 2 de esta lección funcionó porque tú, deliberadamente, guardastehistoryen una variable y la miraste, en el mismo proceso, en el mismo instante. En producción, nadie hace eso por cada run — y si algo falla tres días después, no hay ningúnhistoryguardado en ningún lado para ir a mirar. -
Creer que basta con "loggear el
print(history)completo" y ya está resuelto. Es un paso en la dirección correcta, pero volcar la estructura entera de Python a un log de texto plano es difícil de consultar, difícil de agregar en un lote de miles de runs, y no resuelve el problema de fondo: sigue sin tener costo ni latencia, y sigue sin sobrevivir a unRuntimeErrora menos que se capture durante el run, no después. -
Buscar el costo o la latencia dentro de
tool_result, asumiendo que "debe estar en algún lado". No está. Ninguna de las funciones deagent-fundamentals—dispatch_robust,call_with_timeout,run_reservo_agent— mide tiempo real ni cuenta tokens.call_with_timeoutsí usa un timeout, pero un tope máximo no es lo mismo que una medición: corta si algo tarda demasiado, pero nunca reporta cuánto tardó lo que sí terminó a tiempo. -
Pensar que este límite es un descuido de
agent-fundamentals. No lo es — esa guía nunca prometió observabilidad; prometió un agente que resuelve tareas de varios pasos de forma confiable, y lo cumplió. La ausencia de instrumentación no es un bug de esa guía: es, con precisión, el punto exacto donde termina su alcance y empieza el de esta. -
Subestimar el caso del
RuntimeError. Es tentador pensar "bueno, ese run falló, pero al menos los que sí terminan me dejan algo". El punto de esta sección es que un run que falla es, precisamente, el que más necesitas poder diagnosticar — y es exactamente el que, sin instrumentación construida durante la ejecución (no después), te deja con menos rastro que ningún otro.
Ejercicios
Ejercicio 1: Cuenta las cinco preguntas, una por una (Fácil)
Con el history del ejemplo trabajado (la Forma 2, con el tier inválido) todavía disponible, responde por escrito, para cada una de las cinco preguntas de la lección 01, si la puedes responder (a) con la Forma 1, (b) con la Forma 2, o (c) con ninguna de las dos.
Ver solución
| Pregunta | Forma 1 (solo texto) | Forma 2 (history a mano) |
|---|---|---|
| ¿Cuántos pasos dio? | No | Sí — len(history) == 10 |
| ¿Qué herramientas llamó, en qué orden? | No | Sí — list_rooms, get_quote x2, book_room |
| ¿Algo falló en el camino? | No | Sí — turno [4], is_error |
| ¿Cuánto costó? | No | No — no existe ningún campo de tokens ni centavos |
| ¿Cuánto tardó? | No | No — no existe ningún campo de tiempo |
Explicación: la Forma 2 responde tres de cinco, pero solo si alguien decidió, de antemano, guardar history y mirarla — algo que no ocurre por defecto en un sistema real. Las dos últimas preguntas no las responde ni la Forma 2, porque la información simplemente no se capturó en ningún punto de la ejecución. Ese es el límite exacto que separa "inspeccionar a mano" de "tener instrumentación": la primera exige que alguien esté mirando; la segunda captura la información sin importar si alguien mira o no.
Ejercicio 2: Reproduce el caso del RuntimeError con una tool distinta (Medio)
Repite el escenario de "el run falla" del ejemplo trabajado, pero con un guion que repite get_quote con argumentos válidos indefinidamente (sin end_turn), y max_iterations=3. Confirma que el RuntimeError se lanza y que, otra vez, no queda ningún history disponible fuera de la función.
Ver solución
stuck_get_quote = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": f"toolu_0{n}", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}}]}
for n in range(1, 5)
]
try:
ra.run_reservo_agent("Cotiza Focus pro 3h", stuck_get_quote, max_iterations=3)
except RuntimeError as exc:
print("RuntimeError capturado:", exc)
print("¿'history' quedó definida en este scope?", "history" in dir())
Salida esperada:
RuntimeError capturado: max_iterations alcanzado (3)
¿'history' quedó definida en este scope? False
Explicación: el resultado es idéntico al del ejemplo trabajado, con una tool completamente distinta y argumentos completamente válidos — el RuntimeError no depende de que algo esté mal con los datos, depende únicamente de que el guion nunca llegue a stop_reason: "end_turn" dentro del tope. Esto confirma que la pérdida del history no es un caso especial de un tipo de error: es el comportamiento del tope de iteraciones en sí, sin importar qué tool o qué argumentos lo dispararon.
Ejercicio 3: Diseña, en prosa, el requisito mínimo que resolvería el caso del RuntimeError (Difícil)
Sin escribir código todavía —eso empieza en la lección 08, y se desarrolla a fondo en el Módulo 2—, describe en un párrafo qué tendría que cambiar en cómo se registra la información de un run para que, incluso cuando run_reservo_agent termine en RuntimeError, quede un rastro de los pasos que sí alcanzó a dar antes de fallar.
Ver solución
La única forma de que un run que falla deje rastro es registrar cada paso en el momento en que ocurre, no esperar a que la función termine para decidir qué guardar. Si cada iteración del for dentro de run_reservo_agent —o, sin tocar esa función, una capa que la envuelve desde afuera— escribiera un evento (qué tool se llamó, con qué argumentos, qué resultado volvió) a un destino que sobrevive más allá de la variable local messages —un archivo, un log— entonces un RuntimeError en la iteración 3 todavía dejaría atrás los eventos de las iteraciones 1 y 2, ya escritos antes de que todo reventara. Esto es exactamente la diferencia entre capturar al final (lo que la Forma 2 de esta lección hizo, y lo que falla en el caso del RuntimeError) y capturar en cada paso, según va pasando — el segundo enfoque es el único que sobrevive a que el run entero no termine bien. Envolver run_reservo_agent para lograr esto, sin tocar su código interno, es precisamente lo que arma la lección 08 de este módulo, en una versión mínima; la versión completa —con un trace_id que correlaciona cada evento y un formato estructurado— es el contenido íntegro del Módulo 2.
Resumen y siguiente paso
- Probamos tres formas de "ver adentro" de un run: solo la respuesta final (cero de cinco preguntas respondidas),
historyinspeccionado a mano (tres de cinco), y un run que falla conRuntimeError(ninguna, porquehistoryni siquiera sobrevive para inspeccionarse). - Confirmamos, con código real, que costo y latencia no están en ningún lugar de
history— no es un problema de cómo se mira, es que esa información nunca se capturó. - El caso del
RuntimeErrores la versión más dura del problema: capturar información al final de un run no sirve para los runs que más necesitas diagnosticar, los que no llegan a un final limpio.
Siguiente lección: 04 — Las señales operacionales que importan. Con el problema ya nombrado con precisión, definimos las cuatro señales que sí vale la pena capturar —tasa de error, tasa de fallo por herramienta, costo por run, latencia por run— y calculamos las primeras dos sobre runs reales.
Recursos adicionales
- Anthropic — Tool use (function calling) overview — La forma exacta de
tool_use/tool_resultquehistorycontiene, y sobre la que se construye toda observabilidad futura de esta guía. - Python —
sys.exc_infoy el manejo de excepciones — Por qué el estado local de una función se pierde cuando una excepción se propaga sin ser capturada dentro de esa misma función. - Python — Variables locales y el ciclo de vida de un stack frame — La base técnica exacta de por qué
history/messagesdeja de existir en cuantorun_reservo_agentretorna o lanza una excepción. - Python 3.14 — What's New — La versión con la que se ejecutó cada bloque de código de esta lección, incluido el
RuntimeErrorreal.