Módulo 8: Project The Reservo Agent In Production
Ensamblando la capa de operación
Descripción
La Lección 1 confirmó que los siete archivos reusados importan juntos sin conflicto. Esta lección va un paso más allá: antes de instrumentar un solo run real (eso empieza en la Lección 3), confirma que cada pieza de la capa de operación sigue funcionando por su cuenta, exactamente como en el módulo donde se construyó — y, con eso confirmado, traza el mapa de en qué punto exacto cada pieza se conecta con el agente de Reservo, porque no todas se conectan en el mismo lugar.
Esta distinción importa más de lo que parece a primera vista. traced_run (M2) se conecta reemplazando dispatch_robust — la capa de despacho, un nivel arriba de la tool real. El CircuitBreaker (M6) se conecta reemplazando la entrada de una tool específica en TOOLS — un nivel más abajo, la función real. cost_for_run y latency_model (M3/M4) no se conectan reemplazando nada — leen history, el valor que run_reservo_agent ya produjo, después de que el run terminó. Tres puntos de conexión distintos, para tres formas distintas de instrumentar el mismo sistema. Esta lección los hace visibles, uno por uno, antes de que la Lección 3 los ponga a trabajar juntos sobre un run real.
Conexión con el módulo
Esta lección no construye ninguna pieza nueva — confirma, con un chequeo ejecutado por cada archivo, que las nueve piezas reusadas de M2-M7 siguen produciendo exactamente la salida que ya viste en su módulo de origen. Es la base sobre la que se apoya el resto del capstone: si algo aquí no coincidiera con lo que ya conoces, sería la señal de que algo se rompió en el camino, antes de que la Lección 3 intente construir sobre ese terreno.
Analogía: revisar cada estación de la cocina antes de encender el servicio completo
Antes de que abra el servicio de la noche, el jefe de cocina no confía de memoria en que cada estación está lista — camina por la cocina, una estación a la vez: prueba que el cronómetro de la parrilla marca cero, que la báscula del área de postres pesa correctamente un peso de referencia conocido, que el interruptor térmico del horno está en la posición correcta, que el archivo de recetas tiene las versiones que se supone que tiene. Ninguna de estas revisiones cocina un plato — cada una confirma, por separado, que el instrumento que se va a usar durante el servicio está calibrado. Esta lección es exactamente esa caminata, aplicada a la capa de operación de Reservo: antes de servir un run real con las cuatro disciplinas puestas a la vez, confirma que cada instrumento —el reloj de eventos de M2, la báscula de costo de M3, el cronómetro modelado de M4, el archivo de casos de M5, el interruptor de M6, el registro de versiones de M7— sigue calibrado exactamente como quedó en su propio módulo.
Ejemplo trabajado: seis chequeos, uno por pieza, sin tocar el agente todavía
M2 — run_logger: make_trace_id sigue siendo determinista
import run_logger as rl
t1 = rl.make_trace_id("Reserva Focus pro 3h para Ana", 1)
t1_again = rl.make_trace_id("Reserva Focus pro 3h para Ana", 1)
print("trace_id :", t1)
print("mismo trace_id otra vez:", t1_again)
print("son iguales :", t1 == t1_again)
Qué esperar:
trace_id : run-8487582448eb
mismo trace_id otra vez: run-8487582448eb
son iguales : True
El mismo trace_id de siempre —run-8487582448eb—, el que vas a ver repetirse en cada lección de este módulo que use la pregunta canónica de Ana. Sin este chequeo, cualquier lección posterior que cite ese valor estaría citando algo que ya no podrías reproducir en tu propia máquina.
M3 — cost_calculator: el pricing fijo sigue siendo el mismo
import cost_calculator as cc
print("pricing entrada (centavos/1M tok):", cc.INPUT_PRICE_CENTS_PER_MILLION_TOKENS)
print("pricing salida (centavos/1M tok):", cc.OUTPUT_PRICE_CENTS_PER_MILLION_TOKENS)
print("costo de 64 in / 56 out (Ana) :", cc.estimate_cost_cents(64, 56), "centavos")
Qué esperar:
pricing entrada (centavos/1M tok): 300
pricing salida (centavos/1M tok): 1500
costo de 64 in / 56 out (Ana) : 0 centavos
300/1500 son la constante fijada en el DISEÑO de esta guía —claude-sonnet-5, precio de lista, citado en M3—; 64/56 son los tokens estimados del run de Ana que la Lección 4 de este módulo va a recalcular de punta a punta. 0 centavos sigue siendo la respuesta honesta para un run de este tamaño.
M4 — latency_model: TOOL_LATENCY_MS no cambió un solo valor
import latency_model as lm
for name, ms in sorted(lm.TOOL_LATENCY_MS.items(), key=lambda kv: kv[1]):
print(f" {name:15} {ms:4} ms")
print("percentil 50 de [25, 25, 40, 50, 65, 90, 120, 145, 185, 185, 210, 275]:",
lm.percentile(sorted([25, 25, 40, 50, 65, 90, 120, 145, 185, 185, 210, 275]), 50), "ms")
Qué esperar:
get_quote 25 ms
list_rooms 40 ms
cancel_booking 90 ms
book_room 120 ms
p50 = 90 ms
percentil 50 de [25, 25, 40, 50, 65, 90, 120, 145, 185, 185, 210, 275]: 90 ms
(el print del percentil se muestra abreviado arriba; el valor real es el mismo 90 ms que M4, Lección 6, calculó sobre el lote de doce runs). TOOL_LATENCY_MS sigue exactamente en los cuatro valores que M4 fijó: book_room (120 ms) sigue siendo, con diferencia, la tool más cara — la escritura, contra las lecturas de list_rooms/get_quote y la tool destructiva cancel_booking.
M5 — harness: el CASE_SET sigue teniendo cinco casos, con las mismas anclas
import harness as hn
for c in hn.CASE_SET:
print(f"{c['name']:38} tools={c['expected_tools']}")
Qué esperar:
quote_focus_pro_3h tools=['get_quote']
quote_focus_basic_3h tools=['get_quote']
book_focus_pro_3h_ana tools=['list_rooms', 'get_quote', 'book_room']
book_boardroom_pro_1h_sofia tools=['list_rooms', 'get_quote', 'book_room']
book_and_cancel_studio_basic_1h_diego tools=['book_room', 'cancel_booking']
Los mismos cinco casos que M5 fijó, en el mismo orden, cubriendo las cuatro tools. Ninguno se generó de nuevo, ninguno cambió su guion — la propiedad "fijo" del CASE_SET, confirmada otra vez antes de correr el gate de verdad en la Lección 5.
M6 — tool_circuit_breaker: la máquina de estados arranca CLOSED, y sabe abrirse
from tool_circuit_breaker import CircuitBreaker, CLOSED, OPEN
breaker = CircuitBreaker("book_room", failure_threshold=3, cooldown_calls=2)
print("estado inicial:", breaker.state)
for _ in range(3):
breaker.on_failure()
print("estado tras 3 fallos consecutivos:", breaker.state)
assert breaker.state == OPEN
print("assert OK -- el breaker abre exactamente en el umbral configurado")
Qué esperar:
estado inicial: CLOSED
estado tras 3 fallos consecutivos: OPEN
assert OK -- el breaker abre exactamente en el umbral configurado
failure_threshold=3 sigue siendo el umbral de M6: tres fallos consecutivos, sin ningún éxito de por medio, y el breaker salta a OPEN. La Lección 6 de este módulo va a correr esta misma máquina de estados sobre una tool que falla de verdad, no sobre llamadas directas a on_failure() como este chequeo rápido.
M7 — prompt_registry: los hashes de v1 y v2 siguen siendo los mismos
from prompt_registry import PROMPT_REGISTRY
for version_id, av in PROMPT_REGISTRY.items():
print(f"{version_id}: hash={av.prompt_hash} tools={av.tools_version} nota={av.note!r}")
Qué esperar:
v1: hash=c5757b6d6264 tools=tools-v1 nota='version original, segura'
v2: hash=c364e85e5649 tools=tools-v1 nota='mas proactiva -- rompe quote_focus_pro_3h'
(las notas exactas de cada versión pueden variar en el texto, pero los hashes c5757b6d6264/c364e85e5649 son deterministas y no cambian). hashlib.sha256 sobre el mismo texto de prompt produce, siempre, el mismo hash corto — la propiedad que la Lección 5 de este módulo va a usar para identificar sin ambigüedad qué versión corrió el gate.
Los tres puntos de conexión: dónde se engancha cada pieza
Con los seis chequeos confirmados, vale la pena nombrar con precisión algo que las lecciones anteriores mostraron por separado, pero nunca una al lado de la otra: la capa de operación de esta guía se conecta al agente de Reservo en tres alturas distintas, no en una sola.
run_reservo_agent(question, model_script)
│
├─ ALTURA 1 -- reemplaza rr.dispatch_robust (todo el despacho de UNA tool call)
│ traced_run (M2): loguea tool_use ANTES y tool_result DESPUÉS de
│ CUALQUIER tool, sin importar cuál. Un solo parche, cubre las 4 tools.
│
├─ ALTURA 2 -- reemplaza rc.TOOLS["book_room"] (la función real de UNA tool)
│ CircuitBreaker + call_with_breaker (M6): decide, antes de tocar la
│ función real de ESA tool específica, si la llamada pasa o se rechaza.
│ Un parche POR TOOL -- get_quote y list_rooms nunca se tocan.
│
└─ ALTURA 3 -- lee `history` DESPUÉS de que run_reservo_agent ya retornó
cost_for_run (M3), total_run_latency_ms (M4): no reemplazan nada --
recorren el resultado ya producido, sin ninguna intervención durante
el run.
Esta distinción no es un detalle académico: determina el orden en que las piezas se combinan cuando trabajan juntas. Si el CircuitBreaker de book_room (Altura 2) está OPEN y rechaza una llamada, esa llamada nunca llega a ejecutarse — pero traced_run (Altura 1), que ya envolvió dispatch_robust un nivel más arriba, sigue viendo pasar el intento completo: el tool_use, y después un tool_result con is_error: True cuando CircuitOpenError se convierte en el mismo tipo de error que cualquier otro fallo de tool. Las Alturas 1 y 3 nunca "saben" que existe un circuit breaker por debajo — cada una hace su trabajo con la información que le llega, sin necesitar coordinación explícita con las demás. Esa independencia es, precisamente, lo que hace posible ensamblar cuatro disciplinas construidas por separado sin que ninguna tenga que conocer los detalles internos de las otras tres.
Errores comunes
-
Pensar que
traced_runy elCircuitBreakercompiten por el mismo punto de conexión. No compiten — uno reemplazadispatch_robust(un nivel de despacho), el otro reemplaza la función real de una tool específica dentro deTOOLS(un nivel más abajo). Los dos monkeypatches conviven sin ningún conflicto porque apuntan a atributos distintos, en módulos distintos. -
Ejecutar el chequeo de M6 de esta lección (
breaker.on_failure()tres veces seguidas) y pensar que eso "ya probó" el circuit breaker. No lo probó contra una tool real — solo confirmó que la máquina de estados en sí misma transiciona en el umbral correcto. La prueba real, conbook_roomfallando de verdad yretry_with_backoffde por medio, es el contenido completo de la Lección 6. -
Olvidar que
cost_for_runylatency_modelnunca modifican nada durante el run. A diferencia detraced_runy elCircuitBreaker, ninguna de las dos piezas de medición instala un parche — leenhistoryuna vez querun_reservo_agentya terminó. Si un run falla conRuntimeErrorantes de retornar, no hay ningúnhistoryque estas dos piezas puedan leer — el mismo límite querun_and_observe(M1) ya mostró. -
Suponer que los seis chequeos de esta lección reemplazan el uso real de cada pieza en su propio módulo. No lo hacen — son un chequeo rápido de calibración, no una repetición del contenido de M2-M7. Si alguno de los seis produjera un valor distinto al de esta lección, la señal correcta es volver al módulo de origen, no "arreglarlo" aquí.
-
Cargar
golden_cases.json(M5) con una ruta relativa distinta a la del directorio de trabajo de este capstone.harness.CASE_SETdepende de quegolden_cases.jsonesté en el mismo directorio desde el que corre Python — un error de ruta produce unFileNotFoundErrorfácil de confundir con un problema del propio harness.
Ejercicios
Ejercicio 1: Confirma las cuatro tools en los tres registros (Fácil)
Sin ejecutar ningún run, confirma que rc.TOOLS (agent-fundamentals), lm.TOOL_LATENCY_MS (M4) y las tools que aparecen en hn.CASE_SET (M5) nombran exactamente el mismo conjunto de cuatro tools, sin ninguna de más ni de menos.
Ver solución
import reservo_contracts as rc
import latency_model as lm
import harness as hn
tools_contract = set(rc.TOOLS.keys())
tools_latency = set(lm.TOOL_LATENCY_MS.keys())
tools_cases = {t for c in hn.CASE_SET for t in c["expected_tools"]}
print("tools en reservo_contracts:", sorted(tools_contract))
print("tools en TOOL_LATENCY_MS :", sorted(tools_latency))
print("tools que aparecen en CASE_SET:", sorted(tools_cases))
print("contract == latency:", tools_contract == tools_latency)
print("latency es superset de cases:", tools_latency >= tools_cases)
Salida esperada:
tools en reservo_contracts: ['book_room', 'cancel_booking', 'get_quote', 'list_rooms']
tools en TOOL_LATENCY_MS : ['book_room', 'cancel_booking', 'get_quote', 'list_rooms']
tools que aparecen en CASE_SET: ['book_room', 'cancel_booking', 'get_quote', 'list_rooms']
contract == latency: True
latency es superset de cases: True
Explicación: los tres conjuntos coinciden exactamente. Esta es la propiedad que hace posible que total_run_latency_ms nunca tenga que usar el valor por defecto (.get(name, 0)) sobre una tool real de Reservo — cada tool que el agente puede llamar ya tiene una entrada en TOOL_LATENCY_MS, y el CASE_SET del gate ejercita las cuatro, ninguna de más.
Ejercicio 2: Simula la Altura 1 y la Altura 2 conviviendo, sin tocar el agente todavía (Medio)
Sin correr run_reservo_agent, escribe un dict de dos niveles que represente la idea de "dos monkeypatches activos a la vez": una clave "dispatch_robust_patched" con el valor True si rr.dispatch_robust ya no es la función original del módulo, y una clave "book_room_patched" con el valor True si rc.TOOLS["book_room"] ya no es la función original. Aplica ambos parches (con funciones de prueba simples, sin lógica real) y confirma que los dos pueden estar activos al mismo tiempo sin que uno interfiera con el otro.
Ver solución
import reservo_robust as rr
import reservo_contracts as rc
original_dispatch = rr.dispatch_robust
original_book_room = rc.TOOLS["book_room"]
def fake_dispatch_robust(tool_use_block, max_retries=3, timeout=2.0):
return original_dispatch(tool_use_block, max_retries=max_retries, timeout=timeout)
def fake_book_room(room, tier, hours, member):
return original_book_room(room, tier, hours, member)
rr.dispatch_robust = fake_dispatch_robust
rc.TOOLS["book_room"] = fake_book_room
status = {
"dispatch_robust_patched": rr.dispatch_robust is not original_dispatch,
"book_room_patched": rc.TOOLS["book_room"] is not original_book_room,
}
print(status)
# Restaurar, como hace `finally` en traced_run -- nunca dejar un parche instalado "para siempre".
rr.dispatch_robust = original_dispatch
rc.TOOLS["book_room"] = original_book_room
print("restaurado:", rr.dispatch_robust is original_dispatch, rc.TOOLS["book_room"] is original_book_room)
Salida esperada:
{'dispatch_robust_patched': True, 'book_room_patched': True}
restaurado: True True
Explicación: los dos parches conviven sin ningún conflicto porque apuntan a atributos completamente distintos —uno en el módulo reservo_robust, otro en el diccionario rc.TOOLS—. Restaurar ambos al final, de forma explícita, es la misma disciplina que traced_run aplica con su bloque finally: ningún parche de esta guía se queda instalado más allá del alcance que le corresponde.
Ejercicio 3: Diseña el orden correcto si ambas Alturas fallan a la vez (Difícil)
Imagina que, en el mismo run, el CircuitBreaker de book_room está OPEN (Altura 2) Y, además, quieres que ese rechazo quede registrado en RUN_LOG.jsonl (Altura 1). Describe, en prosa, el orden exacto en que las dos piezas tendrían que actuar para que el rechazo del breaker aparezca como un evento tool_result con is_error: True en el log — sin escribir el código completo, nombra qué excepción tendría que convertirse en qué, y en qué punto.
Ver solución
El orden correcto es: book_room real está envuelta por el CircuitBreaker (Altura 2, la más cercana a la tool); dispatch_robust sigue estando envuelta por traced_run (Altura 1, la más cercana al loop). Cuando el breaker está OPEN, call_with_breaker lanza CircuitOpenError antes de tocar la función real de book_room. Esa excepción sube hasta dispatch_robust —la misma dispatch_robust de agent-fundamentals M7, que ya sabe atrapar cualquier excepción real de una tool y convertirla en un tool_result con is_error: True, sin que el run se caiga—. Como traced_run (M2) ya reemplazó a dispatch_robust con _make_traced_dispatch, y esa envoltura loguea el tool_result después de que dispatch_robust original hizo su trabajo, el resultado final es exactamente el que se buscaba: una línea de log con event: "tool_result", tool: "book_room", is_error: true, y el mensaje de CircuitOpenError como contenido — sin que ninguna de las dos piezas necesitara saber, de antemano, que la otra existía. CircuitOpenError nunca necesita un manejo especial dentro de dispatch_robust: para ese código, es una excepción real de tool, igual que un ConnectionError o un KeyError — el mismo protocolo uniforme de errores que agent-fundamentals M7 construyó desde el principio, ahora demostrando su valor en una integración que ese módulo nunca anticipó explícitamente.
Resumen y siguiente paso
- Confirmamos, con un chequeo rápido por pieza, que las seis funciones/estructuras centrales de M2-M7 —
make_trace_id, el pricing fijo,TOOL_LATENCY_MS, elCASE_SET, la máquina de estados delCircuitBreaker, yPROMPT_REGISTRY— siguen produciendo exactamente lo que ya viste en su módulo de origen. - Trazamos los tres puntos de conexión reales entre la capa de operación y
run_reservo_agent: Altura 1 (dispatch_robust, M2), Altura 2 (la función real de una tool específica, M6), y Altura 3 (leerhistorydespués del run, M3/M4) — tres alturas distintas que conviven sin conflicto. - Confirmamos, con dos monkeypatches activos a la vez, que reemplazar
dispatch_robusty reemplazarTOOLS["book_room"]no interfieren entre sí, y que ambos se restauran limpiamente al terminar.
Siguiente lección: 03 — El run instrumentado. Con la capa de operación calibrada, envolvemos por primera vez a run_reservo_agent con traced_run sobre un lote real de tareas de Reservo — RUN_LOG.jsonl, escrito a disco, y leído de vuelta sin ninguna variable de Python en memoria.
Recursos adicionales
- Python — Modificar atributos de un módulo en tiempo de ejecución — La base técnica de cómo
run_reservo_agentresuelverr.dispatch_robustde nuevo en cada iteración, lo que hace posible el monkeypatching de Altura 1 y Altura 2. - Python —
hashlib— La base dehash_prompt(M7), confirmado de nuevo en el chequeo de esta lección. - Python —
statistics—statistics.quantiles, la base depercentile(M4), confirmado de nuevo en el chequeo de esta lección. - Anthropic — Tool use error handling — El protocolo de
is_errorque hace posible queCircuitOpenError(Altura 2) y cualquier otro fallo real de tool viajen por el mismo camino hasta el log de M2 (Altura 1), sin ningún caso especial.