Módulo 2: El patrón supervisor/router
Qué hace un supervisor
Descripción
El Módulo 1, lección 05, construyó un supervisor con una sola opción posible: delegaba siempre
a booking_agent, porque era el único especialista que existía en ese ejemplo. Esta lección da el
paso que ese supervisor todavía no daba: formaliza las cuatro piezas que cualquier supervisor
necesita —recibir la petición, decidir a quién delegar (entre varios candidatos), despachar la
tarea, y agregar el resultado— y las ejecuta sobre el registro completo de los tres especialistas
de Reservo.
Vas a construir SPECIALISTS, el registro con los tres agentes y su expertise, y run_specialist,
un wrapper mínimo que envuelve run_agent_parallel sin tocarlo —el mismo principio de reutilización
que atraviesa toda la guía—. El ejemplo trabajado repite, paso a paso, la petición "Cotiza Focus
pro 3h y resérvala para Ana" del Módulo 1, pero esta vez la decisión de delegar a booking_agent
es una elección real entre tres opciones, no la única alternativa disponible.
Conexión con el módulo
Esta lección es la base formal sobre la que se apoyan las cinco que siguen: el router determinista
(lección 03) y el router por decisión del modelo (lección 04) son, cada uno, una forma distinta de
resolver el "Paso 1 (decidir)" que esta lección deja como concepto escrito a mano. El registro
SPECIALISTS y el wrapper run_specialist que construyes acá se reusan sin cambios en el resto del
módulo — la lección 05 solo le agrega la implementación real de pricing_agent; la lección 06 los
usa tal cual, sobre las tres peticiones del dispatcher completo.
Analogía: la ficha de cada puesto de la recepción
Retoma la recepción de la lección 01. Antes de que la recepcionista —o el cartel de reglas— pueda
dirigir a nadie, tiene que existir, escrita en algún lado, la ficha de cada puesto: qué piso es,
qué resuelve, a quién atiende. Sin esa ficha, ni las reglas ni una persona pueden decidir nada — no
hay "a dónde" mandar al visitante. SPECIALISTS es exactamente esa ficha, para los tres puestos de
Reservo.
Ejemplo trabajado: el registro de especialistas y el wrapper del runner
Partimos de las mismas piezas del Módulo 1 —las cuatro tools canónicas, el stub de search_docs,
dispatch_parallel y run_agent_parallel de agent-fundamentals M4/M5, sin ningún cambio— y las
organizamos en un registro único.
import concurrent.futures
from dataclasses import dataclass
import reservo_tools as rt
def dispatch_parallel(tool_use_blocks, tools):
"""El de agent-fundamentals M4/M5, sin cambios."""
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):
"""El de agent-fundamentals M4/M5, sin cambios."""
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 de policy_agent, idéntico al del Módulo 1, lección 06."""
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):
return f"[no-show-policy] {POLICY_DOCS['no-show-policy']}"
return "No se encontró una política relevante para esa pregunta."
# El registro: cada especialista, con su set de tools y una frase de
# expertise -- la "ficha del puesto" de la analogía.
SPECIALISTS = {
"booking_agent": {
"tools": {
"list_rooms": rt.list_rooms, "get_quote": rt.get_quote,
"book_room": rt.book_room, "cancel_booking": rt.cancel_booking,
},
"expertise": "cotizar, reservar y cancelar salas",
},
"policy_agent": {
"tools": {"search_docs": search_docs},
"expertise": "responder preguntas de política (cancelación, no-presentación)",
},
"pricing_agent": {
"tools": {"get_quote": rt.get_quote},
"expertise": "comparar el costo de varias salas/tiers en una sola respuesta",
},
}
def run_specialist(name, task, model_script):
"""Envuelve run_agent_parallel sobre el registro de tools del
especialista `name` -- el runner en sí NO cambia; solo se le pasa un
set de tools más angosto según a quién se despachó."""
tools = SPECIALISTS[name]["tools"]
return run_agent_parallel(task, model_script, tools)
@dataclass
class RoutingDecision:
"""La decisión del supervisor: a CUÁL especialista delegar, y por qué."""
target: str
reason: str
TASK = "Cotiza Focus pro 3h y resérvala para Ana"
# Paso 1 (concepto, claude-sonnet-5): el supervisor lee la petición y decide
# a qué especialista delegar -- eligiendo ENTRE los tres, no delegando
# siempre al mismo (a diferencia del Módulo 1, lección 05, donde solo
# existía un especialista posible).
decision = RoutingDecision(
target="booking_agent",
reason="la petición pide cotizar y reservar -- las dos viven en el dominio de booking_agent",
)
print("--- Paso 1 (concepto): decisión del supervisor ---")
print(f"target: {decision.target!r} | reason: {decision.reason!r}")
# Paso 2 (ejecutado): despacho real al especialista elegido.
model_script_booking = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "book_room",
"input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
"Focus pro 3h cuesta 6000 centavos. Reservé la sala para Ana "
"(confirmación #1)."
)}]},
]
print()
print(f"--- Paso 2 (ejecutado): dispatch a {decision.target} ---")
final, history = run_specialist(decision.target, TASK, model_script_booking)
for i, m in enumerate(history):
role, content = m["role"], m["content"]
if isinstance(content, str):
print(f" [{i}] {role:<9} pregunta: {content!r}")
continue
for block in content:
if block["type"] == "tool_use":
print(f" [{i}] {role:<9} tool_use({block['name']}): {block['input']}")
elif block["type"] == "tool_result":
print(f" [{i}] {role:<9} tool_result: {block['content']}")
elif block["type"] == "text":
print(f" [{i}] {role:<9} texto final: {block['text']!r}")
# Paso 3 (concepto): el supervisor agrega/compone la respuesta final.
print()
print("--- Paso 3 (concepto): el supervisor compone la respuesta para el socio ---")
print("respuesta agregada:", final["content"][0]["text"])
Qué esperar:
--- Paso 1 (concepto): decisión del supervisor ---
target: 'booking_agent' | reason: 'la petición pide cotizar y reservar -- las dos viven en el dominio de booking_agent'
--- Paso 2 (ejecutado): dispatch a booking_agent ---
[0] user pregunta: 'Cotiza Focus pro 3h y resérvala para Ana'
[1] assistant tool_use(get_quote): {'room': 'Focus', 'tier': 'pro', 'hours': 3}
[2] user tool_result: {'price_cents': 6000}
[3] assistant tool_use(book_room): {'room': 'Focus', 'tier': 'pro', 'hours': 3, 'member': 'Ana'}
[4] user tool_result: {'booking_id': 1, 'confirmed': True}
[5] assistant texto final: 'Focus pro 3h cuesta 6000 centavos. Reservé la sala para Ana (confirmación #1).'
--- Paso 3 (concepto): el supervisor compone la respuesta para el socio ---
respuesta agregada: Focus pro 3h cuesta 6000 centavos. Reservé la sala para Ana (confirmación #1).
Fíjate en algo importante: el historial de booking_agent es, línea por línea, idéntico al del
Módulo 1, lección 05. Nada cambió dentro del especialista —el runner sigue siendo
run_agent_parallel, sin una sola línea modificada—. Lo que sí cambió es lo que rodea esa
ejecución: ahora el Paso 1 tuvo que elegir booking_agent de entre tres opciones, no delegarle
la única tarea posible a un supervisor que no tenía otra alternativa.
La anatomía formal de un supervisor
Con el ejemplo ejecutado enfrente, las cuatro piezas quedan concretas:
1. RECIBIR -> la petición cruda del socio, tal como llega (TASK, un string)
2. DECIDIR -> (concepto) a cuál especialista delegar, entre los candidatos de SPECIALISTS
3. DESPACHAR -> (ejecutado) run_specialist(target, task, model_script) -- el mismo
run_agent_parallel de siempre, sobre el set de tools del elegido
4. AGREGAR -> (concepto) componer la respuesta final para el socio, a partir
de lo que el especialista devolvió
Compara esto con el patrón del Módulo 1, lección 05: ahí el supervisor también tenía estas cuatro
piezas, pero el Paso 2 (decidir) era trivial —no había nada que elegir, porque SPECIALISTS tenía
un solo miembro—. Acá, SPECIALISTS tiene tres, y el Paso 2 recién se vuelve una decisión real. Las
lecciones 03 y 04 de este módulo son, cada una, una forma distinta de resolver exactamente ese Paso
2: por reglas (determinista) o por el modelo (concepto).
run_specialist: un wrapper, no un runner nuevo
Vale la pena detenerse en una decisión de diseño que se repite en cada lección que sigue:
run_specialist no reimplementa nada del bucle de agent-fundamentals. Es tres líneas que
buscan el registro de tools correcto en SPECIALISTS y se lo pasan a run_agent_parallel, sin
tocar el while ni el protocolo tool_use/tool_result. Esta es exactamente la misma disciplina
del Módulo 1: la orquestación nueva de esta guía nunca reemplaza el runner de
agent-fundamentals — lo envuelve con mecanismos por encima (acá, un registro con nombre; en
la lección 05 del Módulo 1, un AgentMessage).
La ventaja concreta: si mañana agent-fundamentals corrige un bug en run_agent_parallel, ese fix
se propaga automáticamente a los tres especialistas de este módulo, sin tocar una línea de
SPECIALISTS ni de run_specialist.
Errores comunes
-
Pensar que
run_specialistnecesita saber qué tarea le corresponde a cada agente. No — solo recibe unnameya decidido y unmodel_scriptya escrito. La decisión de cuálnameusar vive afuera, en el Paso 1/2 del supervisor (lecciones 03 y 04), nunca dentro del wrapper. -
Olvidar que
SPECIALISTS[name]["tools"]tiene que existir antes de despachar. Si el supervisor decide untargetque no está en el registro —un typo, un nombre inventado—,run_specialistfalla con unKeyErrornormal de Python, no con un mensaje de negocio amigable. El Ejercicio 3 de esta lección lo confirma. -
Confundir el Paso 3 (agregar) con "no hacer nada". En este ejemplo, la respuesta agregada es literalmente el texto que
booking_agentya había producido — pero eso es una coincidencia de esta tarea puntual (un solo especialista, respuesta ya completa), no una regla general. Cuando el Módulo 4 despache a dos especialistas para la misma petición, el Paso 3 sí necesita combinar dos respuestas en una, no solo repetir una. -
Pensar que el registro
SPECIALISTSreemplaza los contratos (input_schema) de cada tool. No —SPECIALISTSsolo agrupa qué funciones puede llamar cada agente. Los contratos completos (RESERVO_TOOLS, con susinput_schema) siguen viviendo donde los construyóagent-fundamentals; esta guía los reusa sin declararlos de nuevo. -
Ejecutar este ejemplo en un proceso que ya tenía reservas. Si
book_roomno devuelvebooking_id: 1, el intérprete ya corrióbook_roomantes en la misma sesión. La solución de siempre: un proceso nuevo, unreservo_tools.BOOKINGSvacío.
Ejercicios
Ejercicio 1: Clasifica tres peticiones por expertise (Fácil)
Sin ejecutar código, usando solo el campo expertise de cada entrada de SPECIALISTS, decide a
mano a cuál especialista correspondería cada una de estas tres peticiones: (a) "¿Qué salas hay
disponibles?"; (b) "¿Puedo cancelar sin cargo si aviso con un día de anticipación?"; (c) "¿Cuál de
las tres salas conviene más para una reunión de 3 horas?".
Ver solución
(a) booking_agent — "qué salas hay disponibles" es exactamente list_rooms, dentro de la
expertise "cotizar, reservar y cancelar salas".
(b) policy_agent — es una pregunta sobre condiciones de cancelación, dentro de "responder
preguntas de política (cancelación, no-presentación)".
(c) pricing_agent — "cuál conviene más" es una comparación entre salas, dentro de "comparar
el costo de varias salas/tiers en una sola respuesta" — no una reserva ni una pregunta de política.
Ejercicio 2: Despacha a policy_agent con el mismo patrón de 4 pasos (Medio)
Repite el ejemplo trabajado de esta lección, pero con una RoutingDecision(target="policy_agent", ...) para la tarea "¿Qué pasa si no me presento a mi reserva?". Escribe el guion de
policy_agent (una tool_use a search_docs, seguida del texto final) y ejecuta los cuatro pasos.
Ver solución
decision_policy = RoutingDecision(
target="policy_agent",
reason="la petición es una pregunta de política, no una transacción",
)
task_policy = "¿Qué pasa si no me presento a mi reserva?"
script_policy = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "search_docs",
"input": {"query": "qué pasa si no me presento a mi reserva"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
"Si no te presentas a una reserva confirmada y no cancelas con "
"al menos 2 horas de anticipación, Reservo cobra el 50% del "
"precio cotizado como cargo por no-presentación."
)}]},
]
final_p, history_p = run_specialist(decision_policy.target, task_policy, script_policy)
print("respuesta agregada:", final_p["content"][0]["text"])
Salida esperada:
respuesta agregada: Si no te presentas a una reserva confirmada y no cancelas con al menos 2 horas de anticipación, Reservo cobra el 50% del precio cotizado como cargo por no-presentación.
Explicación: los cuatro pasos son idénticos a los del ejemplo trabajado —solo cambió el
target de la decisión y, con eso, el set de tools que run_specialist le pasó a
run_agent_parallel. Nada en run_specialist ni en el runner sabe, de antemano, qué tipo de
petición está resolviendo — solo ejecuta el guion sobre el registro de tools que le indicaron.
Ejercicio 3: ¿Qué pasa con un target que no existe? (Difícil)
Construye una RoutingDecision(target="shipping_agent", reason="typo del supervisor") — un nombre
que no está en SPECIALISTS — e intenta despacharla con run_specialist. (a) ¿Qué excepción se
produce, y en qué línea exacta del código? (b) ¿Por qué esa excepción es preferible a que
run_specialist devuelva silenciosamente una respuesta vacía?
Ver solución
decision_bad = RoutingDecision(target="shipping_agent", reason="typo del supervisor")
try:
run_specialist(decision_bad.target, "algo", [])
except KeyError as e:
print(f"KeyError capturado: {e!r}")
Salida real:
KeyError capturado: KeyError('shipping_agent')
(a) La excepción es un KeyError, producida en la línea tools = SPECIALISTS[name]["tools"] dentro de run_specialist — Python no encuentra la clave
"shipping_agent" en el diccionario SPECIALISTS y falla de inmediato, antes de siquiera intentar
llamar a run_agent_parallel.
(b) Un KeyError ruidoso es preferible a una respuesta vacía silenciosa porque hace visible el
error de coordinación en el momento exacto en que ocurre —un typo en el nombre del especialista, o
un router que devolvió un valor que nunca debería devolver—. Si run_specialist "tragara" el error
y devolviera algo vacío, el supervisor podría terminar componiendo una respuesta final vacía o
confusa para el socio, sin ningún rastro de que la causa real fue un target inválido. Este es el
mismo principio de "que falle ruidoso, no silencioso" que ya viste en agent-fundamentals con la
validación de input_schema: un error visible se depura; uno silencioso se descubre tarde, en
producción, con un socio real esperando una respuesta.
Resumen y siguiente paso
- Un supervisor tiene cuatro piezas: recibir la petición, decidir (concepto) a quién delegar, despachar (ejecutado) al especialista elegido, y agregar (concepto) la respuesta final.
- Construimos
SPECIALISTS, el registro de los tres agentes con su expertise, yrun_specialist, un wrapper de tres líneas que envuelverun_agent_parallelsin modificarlo — la misma disciplina de reutilización de toda la guía. - El Paso 2 (decidir) recién se vuelve una decisión real cuando hay más de un candidato — a diferencia del Módulo 1, lección 05, donde el supervisor solo tenía una opción posible.
- Un
targetque no existe enSPECIALISTSfalla ruidoso, con unKeyErrorclaro — una propiedad deseable, no un bug a esconder.
Siguiente lección: 03 — Ruteo determinista con reglas. Construimos la primera forma real de resolver el Paso 2: una función que decide, por palabras clave, sin ningún modelo de por medio.
Recursos adicionales
- Anthropic — Building effective agents — El patrón "routing": clasificar una entrada y dirigirla a un flujo especializado, la forma general que esta lección implementa con
SPECIALISTSyrun_specialist. - Anthropic — Tool use (function calling) overview — El protocolo que cada especialista sigue usando, sin cambios, dentro de
run_agent_parallel. - Python —
dataclasses— El módulo detrás deRoutingDecision, la misma herramienta que ya usaste paraAgentMessageen el Módulo 1. - Python — Diccionarios — La estructura detrás de
SPECIALISTS, la "ficha de cada puesto" de la analogía de esta lección.