Módulo 8: Project The Reservo Agent In Production

La capa de resiliencia

Descripción

Con el agente instrumentado, medido y gateado, esta lección pone en marcha la cuarta disciplina: endurecer. Ninguna de las tres anteriores prepara a Reservo para lo que esta lección simula de verdad: book_room cayéndose de forma sostenida, durante varios "usuarios" seguidos, mientras el resto del sistema sigue corriendo. El CircuitBreaker de M6 —la máquina de tres estados CLOSED/OPEN/HALF_OPEN— es la pieza que le da al agente la memoria que le faltaba: no solo reintentar con backoff dentro de un intento, sino recordar, entre intentos de usuarios distintos, que una tool ya demostró estar muerta.

Esta lección reusa, sin cambiar una línea, el ciclo completo CLOSED → OPEN → HALF_OPEN → CLOSED que M6 (Lección 4) ya construyó y ejecutó, y lo integra con el resto de la capa de operación de este capstone: cada intento queda registrado con la misma disciplina de logging de M2, y el ahorro que el breaker produce se lee con el mismo vocabulario de costo y latencia de M3/M4.

Conexión con el módulo

Esta lección no construye ningún mecanismo nuevo — retoma CircuitBreaker, CircuitOpenError, call_with_breaker y retry_with_backoff de resilience/tool_circuit_breaker.py (M6), y flaky_book_room (M6, Lección 2), y confirma que el punto de conexión que la Lección 2 de este módulo llamó "Altura 2" —reemplazar la función real de una tool específica— funciona exactamente como se describió, con la capa de observabilidad (Altura 1) registrando cada intento.


Analogía: el generador de respaldo, con una bitácora al lado

El restaurante de la introducción de este módulo tiene un generador de respaldo que se enciende solo cuando una estación de la cocina deja de responder. Pero un generador que se enciende en silencio, sin que nadie lo note, es útil a medias — lo que realmente importa, para un negocio que factura y reporta, es la bitácora que queda al lado: a qué hora se encendió, cuántas veces la estación intentó reconectarse antes de rendirse, y cuánto costó (en tiempo, en ingredientes desperdiciados) descubrir la caída antes de que el generador tomara el control. Esta lección conecta las dos piezas: el generador (CircuitBreaker, M6) y la bitácora (log_event, M2) — no porque una dependa de la otra para funcionar, sino porque juntas responden la pregunta completa que un negocio real necesita: no solo "¿el sistema siguió en pie?", sino "¿qué pasó, exactamente, mientras estuvo caído, y cuánto costó?".


Ejemplo trabajado: el ciclo completo, reusado de M6

book_room, cayendo de forma sostenida — nueve llamadas reales antes de recuperarse

import reservo_tools as rt
import reservo_agent as ra
from tool_circuit_breaker import CircuitBreaker, CircuitOpenError, call_with_breaker

_book_room_real = rt.book_room
_state = {"count": 0}
OUTAGE_CALLS = 9


def flaky_book_room(room, tier, hours, member):
    """La misma tool inestable de M6 (Lección 2): falla las primeras nueve
    llamadas con un error TRANSITORIO, después se recupera sola."""
    _state["count"] += 1
    if _state["count"] <= OUTAGE_CALLS:
        raise ConnectionError(f"timeout de red simulado (llamada real #{_state['count']})")
    return _book_room_real(room, tier, hours, member)


breaker = CircuitBreaker("book_room", failure_threshold=3, cooldown_calls=2)

print("=== 7 runs independientes, book_room caído las primeras 9 llamadas reales ===")
for run_n in range(1, 8):
    print(f"--- run {run_n} (estado del breaker ANTES: {breaker.state}) ---")
    try:
        result = call_with_breaker(
            breaker, flaky_book_room, room="Focus", tier="pro", hours=3, member=f"user{run_n}",
            max_retries=3, base_delay_ms=100,
        )
        print(f"  OK -> {result}")
    except CircuitOpenError as exc:
        print(f"  RECHAZADO SIN LLAMAR A LA TOOL: {exc}")
    except ConnectionError as exc:
        print(f"  FALLO (tope de reintentos agotado): {exc}")
    print(f"  estado del breaker DESPUÉS: {breaker.state} (failure_count={breaker.failure_count})")

print()
print(f"llamadas reales totales a book_room: {_state['count']}")

Qué esperar:

=== 7 runs independientes, book_room caído las primeras 9 llamadas reales ===
--- run 1 (estado del breaker ANTES: CLOSED) ---
  FALLO (tope de reintentos agotado): timeout de red simulado (llamada real #3)
  estado del breaker DESPUÉS: CLOSED (failure_count=1)
--- run 2 (estado del breaker ANTES: CLOSED) ---
  FALLO (tope de reintentos agotado): timeout de red simulado (llamada real #6)
  estado del breaker DESPUÉS: CLOSED (failure_count=2)
--- run 3 (estado del breaker ANTES: CLOSED) ---
  FALLO (tope de reintentos agotado): timeout de red simulado (llamada real #9)
  estado del breaker DESPUÉS: OPEN (failure_count=3)
--- run 4 (estado del breaker ANTES: OPEN) ---
  RECHAZADO SIN LLAMAR A LA TOOL: book_room: circuito abierto (OPEN), llamada rechazada sin tocar la tool
  estado del breaker DESPUÉS: OPEN (failure_count=3)
--- run 5 (estado del breaker ANTES: OPEN) ---
  RECHAZADO SIN LLAMAR A LA TOOL: book_room: circuito abierto (OPEN), llamada rechazada sin tocar la tool
  estado del breaker DESPUÉS: OPEN (failure_count=3)
--- run 6 (estado del breaker ANTES: OPEN) ---
  OK -> {'booking_id': 1, 'confirmed': True, 'price_cents': 6000}
  estado del breaker DESPUÉS: CLOSED (failure_count=0)
--- run 7 (estado del breaker ANTES: CLOSED) ---
  OK -> {'booking_id': 1, 'confirmed': True, 'price_cents': 6000}
  estado del breaker DESPUÉS: CLOSED (failure_count=0)

llamadas reales totales a book_room: 11

Recórrelo con el interruptor térmico de M6 en mente: runs 1 y 2 fallan cada uno agotando sus tres reintentos con backoff, sin que el breaker todavía se abra (failure_count sube a 1, después a 2). Run 3 falla otra vez —el tercer fallo consecutivo— y el breaker salta a OPEN. Runs 4 y 5 ni siquiera tocan book_room — rechazados en el mismo instante, sin backoff, sin ningún timeout. Run 6: el cooldown de dos rechazos se cumplió, el breaker deja pasar una sonda HALF_OPEN, la tool ya se recuperó, y el breaker vuelve a CLOSED. Run 7: normal, de principio a fin. Total: 11 llamadas reales para siete runs — no 21, que es lo que costarían siete runs de hasta tres intentos cada uno sin ningún breaker.


Por qué el CircuitBreaker nunca se instala DENTRO de TOOLS

Antes de conectar este ciclo con el resto de la capa de operación, vale la pena resolver una pregunta que la Lección 2 de este módulo dejó abierta: ¿por qué call_with_breaker se llama directamente en el punto donde el agente pediría la tool, en vez de reemplazar la entrada "book_room" de TOOLS con una versión "protegida"? La respuesta es una fricción de integración real, y vale la pena verla con precisión: dispatch_robust (agent-fundamentals M7) ya tiene su propio reintento interno —hasta max_retries=3 intentos, sin backoff, capturando ConnectionError/FutureTimeoutError— antes de convertir un fallo en un tool_result con is_error: True. Si TOOLS["book_room"] fuera una función que internamente ya reintenta con retry_with_backoff (M6), un fallo que agota esos reintentos internos seguiría siendo un ConnectionError real que sube hasta dispatch_robust — y dispatch_robust, al verlo, reintentaría otra vez, disparando un segundo ciclo completo de retry_with_backoff por cada uno de sus propios intentos. El resultado sería reintentos anidados —hasta 3 × 3 = 9 intentos reales por un solo tool_use— exactamente el ruido que este módulo existe para evitar, no para multiplicar.

Por eso call_with_breaker se llama en el punto donde el loop, no dispatch_robust, invocaría la tool — el mismo patrón que M6 (Lección 4) ya usó, y que esta lección reproduce sin cambios. CircuitOpenError, en cambio, sí puede viajar con seguridad a través de dispatch_robust: no es ConnectionError ni FutureTimeoutError, así que cae en la rama genérica except Exception, que nunca reintenta — se convierte, de inmediato, en un tool_result con is_error: True. Esa es, con precisión, la integración segura que la Lección 2 de este módulo (Ejercicio 3) ya trazó: el rechazo del breaker sí llega limpio hasta el log de M2; los reintentos con backoff de M6, en cambio, viven en una capa que nunca se cruza con el reintento propio de M7.


Integrando la bitácora: cada intento, registrado con la disciplina de M2

Con esa frontera clara, esta lección agrega la bitácora al generador — reusando RunEvent/ToolCallEvent y log_event de M2 directamente, sin pasar por traced_run (que parchea dispatch_robust, una capa que este ciclo, a propósito, no usa):

import itertools
import logging

import run_logger as rl

_trace_id = rl.make_trace_id("book_room caido -- lote de 7 usuarios", 1)
_seq = itertools.count(1)

breaker2 = CircuitBreaker("book_room", failure_threshold=3, cooldown_calls=2)
_state2 = {"count": 0}
OUTAGE_CALLS_2 = 9

def flaky_book_room_2(room, tier, hours, member):
    _state2["count"] += 1
    if _state2["count"] <= OUTAGE_CALLS_2:
        raise ConnectionError(f"timeout de red simulado (llamada real #{_state2['count']})")
    return _book_room_real(room, tier, hours, member)

rl.log_event(logging.INFO, rl.RunEvent(seq=next(_seq), trace_id=_trace_id, event="run_started",
                                        question="book_room caido -- lote de 7 usuarios"))
for run_n in range(1, 8):
    step_before = breaker2.state
    try:
        result = call_with_breaker(breaker2, flaky_book_room_2, room="Focus", tier="pro", hours=3,
                                    member=f"user{run_n}", max_retries=3, base_delay_ms=100)
        rl.log_event(logging.INFO, rl.ToolCallEvent(
            seq=next(_seq), trace_id=_trace_id, event="tool_result", step=run_n, tool="book_room",
            is_error=False, content=str(result)))
    except (CircuitOpenError, ConnectionError) as exc:
        rl.log_event(logging.ERROR, rl.ToolCallEvent(
            seq=next(_seq), trace_id=_trace_id, event="tool_result", step=run_n, tool="book_room",
            is_error=True, content=f"{type(exc).__name__}: {exc}"))
rl.log_event(logging.INFO, rl.RunEvent(seq=next(_seq), trace_id=_trace_id, event="run_finished",
                                        question="book_room caido -- lote de 7 usuarios"))

Qué esperar (cada línea, un evento JSON real):

{"seq": 1, "trace_id": "run-1efeabb7e560", "event": "run_started", "question": "book_room caido -- lote de 7 usuarios", "error": ""}
{"seq": 2, ..., "event": "tool_result", "step": 1, "tool": "book_room", "is_error": true, "content": "ConnectionError: timeout de red simulado (llamada real #3)"}
{"seq": 3, ..., "event": "tool_result", "step": 2, "tool": "book_room", "is_error": true, "content": "ConnectionError: timeout de red simulado (llamada real #6)"}
{"seq": 4, ..., "event": "tool_result", "step": 3, "tool": "book_room", "is_error": true, "content": "ConnectionError: timeout de red simulado (llamada real #9)"}
{"seq": 5, ..., "event": "tool_result", "step": 4, "tool": "book_room", "is_error": true, "content": "CircuitOpenError: book_room: circuito abierto (OPEN), llamada rechazada sin tocar la tool"}
{"seq": 6, ..., "event": "tool_result", "step": 5, "tool": "book_room", "is_error": true, "content": "CircuitOpenError: book_room: circuito abierto (OPEN), llamada rechazada sin tocar la tool"}
{"seq": 7, ..., "event": "tool_result", "step": 6, "tool": "book_room", "is_error": false, "content": "{'booking_id': 1, 'confirmed': True, 'price_cents': 6000}"}
{"seq": 8, ..., "event": "tool_result", "step": 7, "tool": "book_room", "is_error": false, "content": "{'booking_id': 1, 'confirmed': True, 'price_cents': 6000}"}
{"seq": 9, "trace_id": "run-1efeabb7e560", "event": "run_finished", "question": "book_room caido -- lote de 7 usuarios", "error": ""}

Esta bitácora dice, de un vistazo y sin volver a correr nada, algo que el ciclo de M6 por sí solo no dejaba escrito en ningún lado: los rechazos del breaker (run 4, run 5) tienen exactamente el mismo event/is_error que un fallo real de la tool (run 1, run 2, run 3) — la diferencia está solo en el content, con el nombre de la excepción. Para cualquier código que lea RUN_LOG.jsonl después —como summarize_by_trace (Lección 3)—, ambos cuentan igual como "un paso que falló"; la distinción entre "la tool falló de verdad" y "el breaker ni siquiera la dejó intentarlo" solo aparece si alguien lee el content con atención, o si el trace_id se cruza con el estado del CircuitBreaker en el momento exacto de cada intento — información que este capstone SÍ tiene, en breaker.state, pero que el archivo de log, por diseño, no duplica.


Lo que el breaker realmente ahorra, con el vocabulario de M3 y M4

11 llamadas reales contra 21 sin ningún breaker no es solo una cifra de "eficiencia" abstracta — es, con precisión, la misma clase de ahorro que M3 y M4 ya enseñaron a medir. Cada una de las diez llamadas evitadas (runs 4 y 5, y los intentos que runs 1-3 habrían gastado de más si el breaker hubiera tardado más en abrir) es una llamada que, en un sistema real conectado a una base de datos o a un servicio externo, habría consumido tiempo de espera hasta el timeout —lo que M4 mide en milisegundos— y, si book_room fuera parte de un tool_result que el modelo (concepto) tiene que leer, tokens de entrada reales —lo que M3 mide en centavos—. El breaker no cambia la fórmula de ninguna de las dos disciplinas; cambia cuántas veces esa fórmula tiene que aplicarse, deteniendo el conteo antes de que empiece, en el mismo instante en que decide que una llamada ni siquiera vale la pena intentarla.


Errores comunes

  1. Envolver TOOLS["book_room"] con call_with_breaker para que dispatch_robust lo use automáticamente. Como se explicó arriba, esto produce reintentos anidados —el reintento interno de M7 sobre el reintento con backoff de M6—, multiplicando llamadas reales en vez de reducirlas. call_with_breaker se llama en el punto donde el loop pediría la tool, nunca dentro del registro que dispatch_robust consulta.

  2. Pensar que CircuitOpenError necesita un manejo especial dentro de dispatch_robust. No lo necesita — cae naturalmente en la rama except Exception genérica, que nunca reintenta y siempre produce un tool_result con is_error: True. El protocolo uniforme de errores de M7 ya resuelve esto sin ningún cambio.

  3. Usar traced_run (que parchea dispatch_robust) alrededor de un ciclo que nunca llama a run_reservo_agent. El parche de traced_run queda instalado, pero inerte, porque nada en este ciclo pasa por dispatch_robust — la instrumentación correcta para este patrón es log_event directo, con RunEvent/ToolCallEvent, como muestra esta lección.

  4. Confundir el content de un tool_result rechazado por el breaker con un fallo real de la tool. Ambos comparten is_error: True en el log — la diferencia (breaker vs. fallo real) vive únicamente en el texto del mensaje. Un sistema real que necesite distinguirlos para alertar de forma distinta tendría que parsear ese texto, o registrar un campo adicional — una extensión razonable, fuera del alcance de esta guía.

  5. Compartir un solo CircuitBreaker entre esta lección y la Lección 5 (el gate). Cada demostración de esta guía crea su propia instancia (breaker, breaker2) — un CircuitBreaker que ya acumuló fallos de un experimento anterior contaminaría el resultado del siguiente, exactamente el mismo tipo de fuga de estado que reset_reservo_state() (M5) existe para evitar en el gate.


Ejercicios

Ejercicio 1: Cuenta cuántos eventos del log tienen is_error: true (Fácil)

Sin volver a correr el ciclo: de las siete líneas tool_result de la bitácora de esta lección, ¿cuántas tienen is_error: true? Nómbralas por run_n.

Ver solución

Cinco de siete: run 1, run 2, run 3 (fallos reales de la tool, tope de reintentos agotado) y run 4, run 5 (rechazados por el breaker, OPEN). Solo run 6 y run 7 tienen is_error: false. Esto coincide exactamente con el ciclo de M6: tres fallos reales abren el circuito, dos rechazos consumen el cooldown, y la sonda de run 6 —ya con la tool recuperada— cierra el ciclo.

Ejercicio 2: Calcula el "peaje de detección" con un failure_threshold distinto (Medio)

Repite el ciclo completo de esta lección (los siete runs) con failure_threshold=5 en vez de 3, manteniendo OUTAGE_CALLS=9. ¿Cuántas llamadas reales se gastan antes de que el breaker se abra, y cuántos runs se resuelven con éxito real (sin pasar por el breaker abierto) antes de esa apertura?

Ver solución
_state3 = {"count": 0}
def flaky_book_room_3(room, tier, hours, member):
    _state3["count"] += 1
    if _state3["count"] <= 9:
        raise ConnectionError(f"timeout de red simulado (llamada real #{_state3['count']})")
    return _book_room_real(room, tier, hours, member)

breaker3 = CircuitBreaker("book_room", failure_threshold=5, cooldown_calls=2)
for run_n in range(1, 8):
    try:
        result = call_with_breaker(breaker3, flaky_book_room_3, room="Focus", tier="pro", hours=3,
                                    member=f"user{run_n}", max_retries=3, base_delay_ms=100)
        print(f"run {run_n}: OK -> {result}")
    except CircuitOpenError as exc:
        print(f"run {run_n}: RECHAZADO -- {exc}")
    except ConnectionError as exc:
        print(f"run {run_n}: FALLO -- {exc}")
print("llamadas reales:", _state3["count"], "estado final:", breaker3.state)

Salida esperada (resumen):

run 1: FALLO -- ... (llamada real #3)
run 2: FALLO -- ... (llamada real #6)
run 3: FALLO -- ... (llamada real #9, agota OUTAGE_CALLS -- la 10a llamada real ya recupera)
run 4: OK -> {'booking_id': ..., 'confirmed': True, 'price_cents': 6000}
...
llamadas reales: 12
estado final: CLOSED

Explicación: con failure_threshold=5, el breaker necesitaría cinco fallos consecutivos para abrirse — pero como OUTAGE_CALLS=9 solo alcanza para tres runs completos de tres intentos fallidos cada uno (9 llamadas reales), la tool se recupera sola en la décima llamada, antes de que el breaker llegue a acumular los cinco fallos que necesitaría para saltar. El breaker nunca se abre en este escenario — un umbral demasiado alto, combinado con una caída que dura menos que ese umbral, deja pasar el apagón completo sin que el breaker aporte ningún ahorro, el mismo error común #2 que M6 (Lección 4) ya advirtió con un failure_threshold de 50.

Ejercicio 3: Diseña el evento de log que distingue un rechazo del breaker de un fallo real (Difícil)

Sin cambiar ToolCallEvent (M2) — que no tiene ningún campo dedicado a esto—, propón cómo un sistema real podría distinguir, en un dashboard de monitoreo, cuántos de los tool_result con is_error: true de un período fueron rechazos del breaker contra fallos reales de la tool, usando solo lo que ya está en RUN_LOG.jsonl. Escribe la función que hace ese conteo.

Ver solución
def count_breaker_rejections_vs_real_failures(events):
    rejected = 0
    real_failures = 0
    for e in events:
        if e["event"] != "tool_result" or not e["is_error"]:
            continue
        if e["content"].startswith("CircuitOpenError"):
            rejected += 1
        else:
            real_failures += 1
    return {"rechazados_por_breaker": rejected, "fallos_reales": real_failures}


# Simulando los eventos de esta lección como una lista de dicts:
events_demo = [
    {"event": "tool_result", "is_error": True, "content": "ConnectionError: timeout de red simulado (llamada real #3)"},
    {"event": "tool_result", "is_error": True, "content": "ConnectionError: timeout de red simulado (llamada real #6)"},
    {"event": "tool_result", "is_error": True, "content": "ConnectionError: timeout de red simulado (llamada real #9)"},
    {"event": "tool_result", "is_error": True, "content": "CircuitOpenError: book_room: circuito abierto (OPEN), llamada rechazada sin tocar la tool"},
    {"event": "tool_result", "is_error": True, "content": "CircuitOpenError: book_room: circuito abierto (OPEN), llamada rechazada sin tocar la tool"},
    {"event": "tool_result", "is_error": False, "content": "{'booking_id': 1, 'confirmed': True, 'price_cents': 6000}"},
]
print(count_breaker_rejections_vs_real_failures(events_demo))

Salida esperada:

{'rechazados_por_breaker': 2, 'fallos_reales': 3}

Explicación: la solución más simple —y, con toda intención, la única que este capstone puede ofrecer sin tocar ToolCallEvent— es parsear el prefijo del content, porque CircuitOpenError y ConnectionError producen mensajes con nombres de excepción distintos y reconocibles. Un sistema real, con más presupuesto de ingeniería, agregaría un campo explícito (rejected_by_breaker: bool) a su evento de log en vez de depender de parsear texto — una mejora real, pero fuera del alcance $0 y "sin tocar lo que ya funciona" de esta guía.


Resumen y siguiente paso

  • Reusamos, sin cambiar una línea, el ciclo completo CLOSED → OPEN → HALF_OPEN → CLOSED de M6: siete runs, tres fallos reales, dos rechazos instantáneos, una sonda exitosa — 11 llamadas reales contra las 21 que habría costado sin ningún breaker.
  • Explicamos, con precisión técnica, por qué call_with_breaker nunca se instala dentro de TOOLS: dispatch_robust (M7) ya reintenta ConnectionError internamente, y componer los dos reintentos anidaría hasta 3 × 3 intentos reales por tool call — el motivo real detrás de la Altura 2 que la Lección 2 de este módulo trazó.
  • Confirmamos que CircuitOpenError sí viaja con seguridad por dispatch_robust (cae en la rama genérica, sin reintento), y construimos la bitácora completa de los siete intentos con RunEvent/ToolCallEvent de M2 — el rechazo del breaker y el fallo real comparten is_error: true, distinguibles solo por su content.
  • Tradujimos el ahorro del breaker al vocabulario de M3/M4: cada llamada evitada es tiempo y, en un sistema conectado de verdad, tokens que nunca se gastaron.

Siguiente lección: 07 — Lo que tu agente todavía necesita. Con las cuatro disciplinas ya operando juntas, cerramos el mapa del ecosistema: a dónde ir cuando este agente operado tenga que enfrentar incidentes de infraestructura, juicio semántico, reducción de costo real, o endurecimiento contra ataques.


Recursos adicionales

  1. resilience-and-reliability-patterns-guide (Módulo 5, "Circuit breakers") — la fuente canónica del vocabulario CLOSED/OPEN/HALF_OPEN y de su medición a fondo con exhaustion de pool de threads, retry storms y bulkheads, para cualquier dependencia HTTP genérica más allá de una tool de un agente.
  2. Anthropic — Tool use error handling — El protocolo is_error uniforme que hace posible que CircuitOpenError viaje limpio a través de dispatch_robust, sin ningún caso especial.
  3. Python — Excepciones y jerarquía de Exception — Por qué except (ConnectionError, FutureTimeoutError) no atrapa CircuitOpenError, la base técnica de la frontera de esta lección.
  4. sre-and-incident-response-guide — cuando este mismo patrón de "detectar, aislar, recuperar" necesite operar a nivel de infraestructura completa (un servicio caído, no una tool de un agente), con roles de incidente y postmortems.