Módulo 8: Project The Reservo Multi Agent System
Ensamblando el supervisor y los especialistas
Descripción
Todo sistema multi-agente necesita una puerta de entrada: un registro que sepa qué especialista
existe, qué tools tiene, y una forma uniforme de correr cualquiera de ellos sobre una tarea. Esa
puerta ya la construiste en el Módulo 2 —SPECIALISTS y run_specialist— y esta lección no le
cambia una sola línea. Lo que hace es confirmar, con dos pruebas ejecutadas, que sigue funcionando
exactamente igual ahora que es la base de un sistema completo, y no solo de un módulo aislado.
Vas a correr dos smoke tests: booking_agent respondiendo con una tool de cero argumentos
(list_rooms), y policy_agent recibiendo una pregunta que no está en su stub de 2-3
entradas — el caso límite que confirma que el stub es honesto sobre sus propios límites, no que
"siempre encuentra algo".
Conexión con el módulo
Esta lección es el primer bloque del sistema que las lecciones 03 a 06 van a construir encima:
SPECIALISTS es el registro que run_pipeline (M3), run_tracks_parallel (M4) y
run_with_handoff (M5) consultan, sin excepción, para saber qué tools tiene cada agente. Si algo
falla acá —un nombre de tool mal escrito, un especialista faltante— falla en cascada en cada
lección siguiente. Por eso el sistema completo empieza confirmando esta pieza sola, antes de
apilarle nada encima.
Analogía: el directorio del edificio, antes de abrir al público
Un edificio de oficinas con varios puestos de atención no abre sus puertas sin que, primero, alguien confirme que el directorio de la entrada está correcto: qué piso tiene cada oficina, qué trámites resuelve cada una. Si el directorio dice "Reservas — Piso 2" pero la oficina de Piso 2 en realidad resuelve otra cosa, cualquier visitante que confíe en el cartel termina perdido, sin importar qué tan bien atienda esa oficina una vez que lo encuentran por casualidad.
Esta lección es exactamente ese chequeo del directorio: antes de construir el pipeline, el fan-out
o el handoff sobre SPECIALISTS, confirmamos que el registro mismo —quién es cada agente, qué
tools tiene— sigue siendo correcto.
Ejemplo trabajado: el registro y dos smoke tests
import concurrent.futures
import reservo_tools as rt
def dispatch_parallel(tool_use_blocks, tools):
with concurrent.futures.ThreadPoolExecutor(max_workers=len(tool_use_blocks)) as pool:
futures = [pool.submit(tools[b["name"]], **b["input"]) for b in tool_use_blocks]
results = [f.result() for f in futures]
return [
{"type": "tool_result", "tool_use_id": b["id"], "content": str(r)}
for b, r in zip(tool_use_blocks, results)
]
def run_agent_parallel(question, model_script, tools, max_iterations=10):
messages = [{"role": "user", "content": question}]
for step in range(max_iterations):
turn = model_script[step]
messages.append({"role": "assistant", "content": turn["content"]})
if turn["stop_reason"] != "tool_use":
return turn, messages
tool_result_blocks = dispatch_parallel(turn["content"], tools)
messages.append({"role": "user", "content": tool_result_blocks})
raise RuntimeError(f"max_iterations alcanzado ({max_iterations})")
POLICY_DOCS = {
"no-show-policy": (
"Si un miembro no se presenta a una reserva confirmada y no cancela "
"con al menos 2 horas de anticipación, Reservo cobra el 50% del "
"precio cotizado como cargo por no-presentación."
),
"cancellation-policy": (
"Las reservas se pueden cancelar sin cargo hasta 2 horas antes del "
"horario reservado. Cancelaciones dentro de esas 2 horas aplican "
"el cargo de no-presentación."
),
}
def search_docs(query):
"""Stub mínimo -- 2-3 entradas por palabra clave, NO un índice real.
Stand-in explícito de la tool de `production-rag-and-document-ingestion-guide`."""
q = query.lower()
if "cancela" in q:
return f"[cancellation-policy] {POLICY_DOCS['cancellation-policy']}"
if "no" in q and ("present" in q or "show" in q or "llega" in q):
return f"[no-show-policy] {POLICY_DOCS['no-show-policy']}"
return "No se encontró una política relevante para esa pregunta."
SPECIALISTS = {
"booking_agent": {
"tools": {
"list_rooms": rt.list_rooms, "get_quote": rt.get_quote,
"book_room": rt.book_room, "cancel_booking": rt.cancel_booking,
},
},
"policy_agent": {"tools": {"search_docs": search_docs}},
"pricing_agent": {"tools": {"get_quote": rt.get_quote}},
}
def run_specialist(name, task, model_script):
tools = SPECIALISTS[name]["tools"]
return run_agent_parallel(task, model_script, tools)
print("--- registro de especialistas del sistema ---")
for name, spec in SPECIALISTS.items():
print(f" {name:15} tools={list(spec['tools'])}")
print()
print("--- smoke test 1: booking_agent responde con list_rooms (cero argumentos) ---")
model_script_rooms = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "list_rooms", "input": {}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Reservo tiene 3 salas: Focus, Studio y Boardroom."}]},
]
final_1, history_1 = run_specialist("booking_agent", "¿Qué salas tiene Reservo?", model_script_rooms)
print("respuesta:", final_1["content"][0]["text"])
print()
print("--- smoke test 2: policy_agent, una pregunta FUERA del stub (2-3 entradas) ---")
model_script_miss = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "search_docs",
"input": {"query": "¿cuál es el horario de atención del Boardroom?"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "No tengo esa información en mis documentos de política."}]},
]
final_2, history_2 = run_specialist(
"policy_agent", "¿Cuál es el horario de atención del Boardroom?", model_script_miss,
)
print("tool_result de search_docs:", history_2[2]["content"][0]["content"])
print("respuesta:", final_2["content"][0]["text"])
Qué esperar:
--- registro de especialistas del sistema ---
booking_agent tools=['list_rooms', 'get_quote', 'book_room', 'cancel_booking']
policy_agent tools=['search_docs']
pricing_agent tools=['get_quote']
--- smoke test 1: booking_agent responde con list_rooms (cero argumentos) ---
respuesta: Reservo tiene 3 salas: Focus, Studio y Boardroom.
--- smoke test 2: policy_agent, una pregunta FUERA del stub (2-3 entradas) ---
tool_result de search_docs: No se encontró una política relevante para esa pregunta.
respuesta: No tengo esa información en mis documentos de política.
Los dos smoke tests confirman dos cosas distintas. El primero prueba que run_specialist maneja
sin problema una tool que no recibe ningún argumento (list_rooms()), algo que ninguna lección
anterior de esta guía había ejercitado explícitamente. El segundo confirma algo más importante
para el resto del sistema: el stub de search_docs no inventa una respuesta cuando la
pregunta no coincide con ninguna de sus 2-3 entradas fijas — devuelve honestamente "No se encontró
una política relevante", y el guion concepto de policy_agent respeta esa honestidad en su
respuesta final, en vez de alucinar una política que no existe. Esto importa para las lecciones
siguientes: cualquier pregunta de política que SÍ dispare policy_agent en las Demos A, B y C de
este módulo tiene que caer dentro de las 2-3 entradas reales del stub (cancelación o
no-presentación) — si no, el sistema lo va a decir explícitamente, no lo va a disimular.
El supervisor de este sistema: qué decide, qué no decide
SPECIALISTS y run_specialist son el registro — pero el registro por sí solo no decide nada.
Lo que convierte este registro en un sistema es que, encima de él, hay un supervisor que,
frente a una petición de Reservo, aplica el criterio completo de M7 L02 (las tres preguntas) para
decidir: (1) cuántas sub-tareas independientes tiene la petición, (2) qué patrón —pipeline,
fan-out u handoff— resuelve cada una, y (3) qué datos de cada sub-tarea hacen falta escribir al
Blackboard para el resto de la corrida.
Esa decisión sigue siendo concepto — el supervisor de este sistema, corriendo
claude-sonnet-5, la toma leyendo la petición completa, exactamente como lo hizo en cada lección
de M7. Lo que SÍ se ejecuta de verdad, y es lo que las lecciones 03 a 06 de este módulo construyen,
es la traducción de esa decisión a código real: el PLAN de Track que dice qué mecanismo
(run_pipeline, run_tracks_parallel, run_with_handoff) resuelve cada parte, y el despacho
real de cada uno.
Petición del socio
|
v
[Supervisor decide -- CONCEPTO, claude-sonnet-5]
aplica las tres preguntas de M7 L02 a cada parte de la petición
|
v
[PLAN = lista de Track -- EJECUTADO]
cada Track dice: qué sub-tarea, qué patrón (pipeline/fanout/handoff)
|
v
[Despacho real -- EJECUTADO]
run_pipeline / run_tracks_parallel / run_with_handoff, según corresponda
|
v
[Blackboard -- EJECUTADO]
lo que otras partes del sistema necesitan después, se escribe acá
Nada de esto es nuevo en este módulo — es exactamente la arquitectura que M7 ya construyó. Lo único que agrega esta lección es confirmar, con los dos smoke tests de arriba, que la pieza más básica de esa arquitectura —el registro de especialistas— sigue intacta antes de apilarle el resto del sistema encima, en las lecciones 03 a 06.
Errores comunes
-
Pensar que
SPECIALISTSdecide algo. Es un diccionario — no toma ninguna decisión. La decisión de qué especialista(s) necesita una petición la toma el supervisor (concepto); el registro solo le dice, una vez decidido, qué tools tiene disponibles cada uno. -
Asumir que el stub de
search_docs"siempre encuentra algo". El smoke test 2 demuestra lo contrario a propósito — cualquier pregunta de política que no coincida con las 2-3 entradas fijas del stub devuelve honestamente "no se encontró", no una alucinación. -
Olvidar que
pricing_agentno tiene ninguna tool exclusiva. Ya lo estableció M1 L02: comparteget_quoteconbooking_agent, y sigue siendo un especialista legítimo porque lo que lo distingue es su objetivo (comparar, no reservar), no su inventario de funciones. -
Llamar a
run_specialistcon un nombre que no está enSPECIALISTS. El registro no valida nombres desconocidos —lanzaría unKeyErrornormal de diccionario—; la validación de qué especialista existe es responsabilidad del supervisor (concepto) al armar elPLAN, no derun_specialist.
Ejercicios
Ejercicio 1: Un tercer smoke test, para pricing_agent (Fácil)
Escribe y ejecuta un tercer smoke test que confirme que pricing_agent responde correctamente a
una sola cotización (Focus pro 3h), sin comparar nada todavía —un caso de una sola tool call, como
los dos ejemplos de esta lección.
Ver solución
model_script_single_quote = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Focus pro 3h cuesta 6000 centavos."}]},
]
final_3, history_3 = run_specialist("pricing_agent", "¿Cuánto cuesta Focus pro 3h?", model_script_single_quote)
print("respuesta:", final_3["content"][0]["text"])
Salida esperada:
respuesta: Focus pro 3h cuesta 6000 centavos.
Explicación: 6000 = 2500 * 3 * 80 // 100 — la misma ancla de esta guía desde el Módulo 1,
confirmando que pricing_agent calcula correctamente incluso en el caso más simple posible (una
sola tool call, sin comparar nada).
Ejercicio 2: ¿Qué pasaría si SPECIALISTS tuviera un especialista con el registro vacío? (Medio)
Sin ejecutar código todavía, imagina SPECIALISTS["feedback_agent"] = {"tools": {}} — un
especialista sin ninguna tool. ¿Qué pasaría si run_specialist("feedback_agent", "...", guion)
intentara correr un guion donde el primer turno tuviera stop_reason: "tool_use"? Después,
verifica tu predicción ejecutando el caso.
Ver solución
SPECIALISTS["feedback_agent"] = {"tools": {}}
model_script_empty = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "record_feedback", "input": {}}]},
]
try:
run_specialist("feedback_agent", "Registra mi satisfacción", model_script_empty)
except KeyError as e:
print(f"KeyError: {e}")
Salida esperada:
KeyError: 'record_feedback'
Explicación: dispatch_parallel busca tools[b["name"]] para despachar la tool call — si el
registro de tools de feedback_agent está vacío, buscar "record_feedback" en un diccionario
vacío lanza KeyError, exactamente el mismo tipo de error que ya viste en agent-fundamentals
cuando un guion pide una tool que no existe en el registro. Este es precisamente el tipo de
"rol decorativo" que el Módulo 1 de esta guía advirtió: un especialista sin tools reales no puede
resolver ninguna tarea que dependa de una — declararlo en SPECIALISTS no alcanza, necesita
tools reales detrás.
Ejercicio 3: ¿Por qué el supervisor no está representado como una función en esta lección? (Difícil)
Revisa el diagrama de "El supervisor de este sistema" de esta lección. Notarás que no hay ninguna
función Python llamada supervisor() o similar en el código ejecutado de esta lección — solo
SPECIALISTS y run_specialist. Explica por qué, siguiendo la regla dura de ejecución de esta
guía completa.
Ver solución
Porque la decisión del supervisor —leer la petición completa y decidir qué patrón le corresponde a
cada parte— es exactamente el tipo de decisión que esta guía declara concepto, no ejecutado:
es una decisión del modelo (claude-sonnet-5), no un cálculo determinista. Escribir una función
supervisor() que "decida" en código sería fingir que esa decisión es mecánica cuando en realidad
requiere comprensión de lenguaje natural — exactamente lo que M2 (routing-by-model-decision) ya
estableció al distinguir el enrutamiento determinista (reglas/keywords, SÍ ejecutable) del
enrutamiento por decisión del modelo (concepto). Lo que SÍ se ejecuta, y es lo único que el código
de esta lección y las siguientes representa, es la traducción de esa decisión ya tomada a un
PLAN de Track y su despacho real — el PLAN en sí (las lecciones 03, 04 y 05 lo muestran) se
escribe directamente en el código de cada demo, como el resultado YA DECIDIDO por el supervisor
concepto, no como el producto de una función que "decide" en tiempo de ejecución.
Resumen y siguiente paso
SPECIALISTSyrun_specialist(M2), sin ningún cambio, siguen siendo la puerta de entrada del sistema completo — confirmado con dos smoke tests ejecutados: una tool de cero argumentos y una pregunta fuera del stub desearch_docs.- El stub de
search_docses honesto sobre sus límites: fuera de sus 2-3 entradas fijas, responde "no se encontró", nunca inventa una política. - El supervisor de este sistema sigue siendo una decisión concepto —qué patrón le corresponde a
cada parte de una petición—; lo que se ejecuta es la traducción de esa decisión a un
PLANdeTracky su despacho real.
Siguiente lección: 03 — Conectando el pipeline al sistema. La primera demo del capstone: Luis pide reservar una sala validando la política antes de confirmar — una petición que dispara solo pipeline.
Recursos adicionales
- Anthropic — Tool use (function calling) overview — El protocolo
tool_use/tool_resultquerun_specialistsigue respetando, sin cambios, como base de todo el sistema. - Anthropic — Building effective agents — La distinción entre un agente con varias tools y un sistema de varios agentes especializados, la base de por qué
SPECIALISTStiene tres entradas y no una sola con diez tools. - Python — Diccionarios — La estructura detrás del registro
SPECIALISTS, sin cambios desde M1 L02. - Python — Manejo de excepciones — El comportamiento de
KeyErrorque el Ejercicio 2 de esta lección ejercita al buscar una tool que no existe en un registro vacío.