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

  1. Pensar que run_specialist necesita saber qué tarea le corresponde a cada agente. No — solo recibe un name ya decidido y un model_script ya escrito. La decisión de cuál name usar vive afuera, en el Paso 1/2 del supervisor (lecciones 03 y 04), nunca dentro del wrapper.

  2. Olvidar que SPECIALISTS[name]["tools"] tiene que existir antes de despachar. Si el supervisor decide un target que no está en el registro —un typo, un nombre inventado—, run_specialist falla con un KeyError normal de Python, no con un mensaje de negocio amigable. El Ejercicio 3 de esta lección lo confirma.

  3. Confundir el Paso 3 (agregar) con "no hacer nada". En este ejemplo, la respuesta agregada es literalmente el texto que booking_agent ya 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.

  4. Pensar que el registro SPECIALISTS reemplaza los contratos (input_schema) de cada tool. No — SPECIALISTS solo agrupa qué funciones puede llamar cada agente. Los contratos completos (RESERVO_TOOLS, con sus input_schema) siguen viviendo donde los construyó agent-fundamentals; esta guía los reusa sin declararlos de nuevo.

  5. Ejecutar este ejemplo en un proceso que ya tenía reservas. Si book_room no devuelve booking_id: 1, el intérprete ya corrió book_room antes en la misma sesión. La solución de siempre: un proceso nuevo, un reservo_tools.BOOKINGS vací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, y run_specialist, un wrapper de tres líneas que envuelve run_agent_parallel sin 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 target que no existe en SPECIALISTS falla ruidoso, con un KeyError claro — 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

  1. 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 SPECIALISTS y run_specialist.
  2. Anthropic — Tool use (function calling) overview — El protocolo que cada especialista sigue usando, sin cambios, dentro de run_agent_parallel.
  3. Python — dataclasses — El módulo detrás de RoutingDecision, la misma herramienta que ya usaste para AgentMessage en el Módulo 1.
  4. Python — Diccionarios — La estructura detrás de SPECIALISTS, la "ficha de cada puesto" de la analogía de esta lección.