Módulo 2: El patrón supervisor/router

Mini-proyecto: el supervisor de Reservo

Descripción

Siete lecciones te dejaron con un supervisor completo: la anatomía de cuatro pasos (02), el ruteo determinista (03) y por decisión del modelo (04), el tercer especialista construido (05), el dispatcher de punta a punta (06), y un router híbrido con sus límites reales, no idealizados (07). Este mini-proyecto no agrega ningún concepto nuevo — te da cinco escenarios de Reservo que nunca viste y te pide aplicar el supervisor completo del módulo: rutear, despachar, y citar el costo de cada uno.

El entregable de esta lección es el supervisor completo corriendo contra los cinco escenarios del encargo, más el juicio para reconocer, en al menos un caso, dónde el router determinista puro llega a su límite —el mismo tipo de límite que la lección 07 ya mostró que existe, ahora sobre un escenario que nunca ejecutaste.

Conexión con el módulo

Este mini-proyecto es la síntesis de las siete lecciones anteriores, no una lección nueva. De la 02 usas SPECIALISTS y run_specialist. De la 03, route_deterministic sin ningún cambio. De la 04 y la 07, el criterio para reconocer cuándo el ruteo por reglas no alcanza. De la 05, el tercer especialista terminado. De la 06, la forma de citar el costo de cada camino. Cuando termines, el Módulo 3 toma este mismo Reservo y construye el segundo patrón: el pipeline secuencial, donde el orden de los pasos está fijo de antemano, sin ninguna decisión de ruteo en el medio.


El encargo

Reservo te pasa cinco escenarios que llegaron la misma semana:

Escenario A: "Necesito reservar el Boardroom pro 2h para Marta."
Escenario B: "¿Cuánto me cobran si cancelo con menos de 2 horas de anticipación?"
Escenario C: "Compara Studio y Boardroom, tier pro, 2 horas cada una."
Escenario D: "Cancela mi reserva número 7."
Escenario E: "¿Qué política aplica para grupos corporativos grandes?"

Tu encargo tiene dos entregables:

a) Para cada escenario, aplica route_deterministic y confirma a qué especialista rutea (o si devuelve None, señalando que necesitaría el fallback de la lección 07).

b) Para cada escenario que sí rutea, despachalo con run_specialist y confirma la respuesta final, citando el costo de coordinación de cada camino.


La solución completa (el entregable)

Ver la solución completa
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):
    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."


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):
    tools = SPECIALISTS[name]["tools"]
    return run_agent_parallel(task, model_script, tools)


POLICY_KEYWORDS = ("política", "no-show", "no me presento", "no llego", "cargo por")
PRICING_KEYWORDS = ("compara", " vs ", "más barata", "conviene")
BOOKING_KEYWORDS = ("cotiza", "reserva", "resérva", "agenda", "cancela")


def route_deterministic(text):
    t = text.lower()
    if any(kw in t for kw in POLICY_KEYWORDS):
        return "policy_agent"
    if any(kw in t for kw in PRICING_KEYWORDS):
        return "pricing_agent"
    if any(kw in t for kw in BOOKING_KEYWORDS):
        return "booking_agent"
    return None


# --- Escenario A ---
task_a = "Necesito reservar el Boardroom pro 2h para Marta."
script_a = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Boardroom", "tier": "pro", "hours": 2}}]},
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_02", "name": "book_room",
         "input": {"room": "Boardroom", "tier": "pro", "hours": 2, "member": "Marta"}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Boardroom pro 2h cuesta 12800 centavos. Reservé la sala para Marta (confirmación #1)."}]},
]

# --- Escenario C ---
task_c = "Compara Studio y Boardroom, tier pro, 2 horas cada una."
script_c = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Studio", "tier": "pro", "hours": 2}},
        {"type": "tool_use", "id": "toolu_02", "name": "get_quote",
         "input": {"room": "Boardroom", "tier": "pro", "hours": 2}},
    ]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Studio pro 2h: 6400 centavos. Boardroom pro 2h: 12800 centavos. Studio es la opción más barata de las dos."}]},
]

# --- Escenario D ---
task_d = "Cancela mi reserva número 7."
script_d = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "cancel_booking", "input": {"id": 7}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "No encontré ninguna reserva activa con el número 7 -- revisa el número e intenta de nuevo."}]},
]

# --- Escenario E ---
task_e = "¿Qué política aplica para grupos corporativos grandes?"
script_e = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "search_docs",
         "input": {"query": "política para grupos corporativos grandes"}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "No encontré una política específica para grupos corporativos grandes en nuestra base -- te recomiendo escribir directamente al equipo de cuentas corporativas."}]},
]

for label, task, script in [("A", task_a, script_a), ("C", task_c, script_c),
                             ("D", task_d, script_d), ("E", task_e, script_e)]:
    target = route_deterministic(task)
    print(f"=== Escenario {label} ===")
    print("texto:", repr(task))
    print("ruteo determinista ->", target)
    final, hist = run_specialist(target, task, script)
    print("respuesta:", final["content"][0]["text"])
    print()

# --- Escenario B: aparte, porque route_deterministic devuelve None ---
task_b = "¿Cuánto me cobran si cancelo con menos de 2 horas de anticipación?"
print("=== Escenario B ===")
print("texto:", repr(task_b))
target_b = route_deterministic(task_b)
print("ruteo determinista ->", target_b, "(None -- necesitaría el fallback de la lección 07)")

Qué esperar:

=== Escenario A ===
texto: 'Necesito reservar el Boardroom pro 2h para Marta.'
ruteo determinista -> booking_agent
respuesta: Boardroom pro 2h cuesta 12800 centavos. Reservé la sala para Marta (confirmación #1).

=== Escenario C ===
texto: 'Compara Studio y Boardroom, tier pro, 2 horas cada una.'
ruteo determinista -> pricing_agent
respuesta: Studio pro 2h: 6400 centavos. Boardroom pro 2h: 12800 centavos. Studio es la opción más barata de las dos.

=== Escenario D ===
texto: 'Cancela mi reserva número 7.'
ruteo determinista -> booking_agent
respuesta: No encontré ninguna reserva activa con el número 7 -- revisa el número e intenta de nuevo.

=== Escenario E ===
texto: '¿Qué política aplica para grupos corporativos grandes?'
ruteo determinista -> policy_agent
respuesta: No encontré una política específica para grupos corporativos grandes en nuestra base -- te recomiendo escribir directamente al equipo de cuentas corporativas.

=== Escenario B ===
texto: '¿Cuánto me cobran si cancelo con menos de 2 horas de anticipación?'
ruteo determinista -> None (None -- necesitaría el fallback de la lección 07)

El razonamiento por escenario:

Escenario A — booking_agent, sin sorpresas. "Reservar" matchea BOOKING_KEYWORDS, y la tarea vive completa dentro de su expertise: cotizar y reservar.

Escenario B — el caso que rompe el router puro. Ninguna POLICY_KEYWORD está presente literalmente ("cuánto me cobran" no es "cargo por"; "cancelo" no es exactamente "cancela" —fíjate que la conjugación cambia la substring—), así que route_deterministic devuelve None. A diferencia de la petición ambigua de las lecciones 03/04/07 —que fallaba con confianza—, acá el router es honesto: "no sé". Este es exactamente el modo de falla que el híbrido de la lección 07 sí resuelve bien —caería al fallback de decisión del modelo, que leería la intención real (una pregunta sobre cargo, dominio de policy_agent) y acertaría.

Escenario C — pricing_agent. "Compara" matchea PRICING_KEYWORDS sin ambigüedad; las dos cotizaciones (Studio pro 2h = 6400, Boardroom pro 2h = 12800) van en el mismo turno porque son independientes entre sí, igual que en la lección 05.

Escenario D — booking_agent, y una reserva que no existe. "Cancela" matchea BOOKING_KEYWORDS —correctamente esta vez, porque la intención SÍ es cancelar una reserva puntual, no preguntar sobre un cargo—. cancel_booking(id=7) sobre un Reservo recién iniciado, sin ninguna reserva número 7 todavía, devuelve {'cancelled': False} — un resultado real y válido, no un error. La respuesta final está anclada en ese resultado, no inventa que la reserva sí existía.

Escenario E — policy_agent, ruteo correcto, pero cobertura del stub limitada. "Política" rutea sin ambigüedad a policy_agent — el router acertó el especialista—. Pero search_docs no tiene ninguna entrada para "grupos corporativos", así que devuelve el mensaje de "no encontrado". Esto es una distinción importante: el ruteo funcionó perfecto (fue al especialista correcto); el límite está en la cobertura del stub de ese especialista, un problema distinto, del tipo que el Módulo 1, lección 06, ya señaló como límite explícito del stub mínimo de search_docs.


Errores comunes

  1. Forzar un ruteo para el Escenario B en vez de reconocer el None. El punto de este escenario es justamente reconocer el límite del router puro — inventar una regla ad hoc solo para este caso puntual, sin pensar en el resto del vocabulario, es el tipo de parche frágil que la lección 07 ya advirtió contra.

  2. Confundir el Escenario E (ruteo correcto, respuesta del stub limitada) con el Escenario B (ruteo que ni siquiera decide). Son dos tipos de límite completamente distintos —uno es del router, el otro es del especialista— y requieren mitigaciones distintas (fallback híbrido para B; ampliar el corpus de search_docs, fuera del alcance de esta guía, para E).

  3. Pensar que el Escenario D "falló" porque cancelled: False. No falló — cancel_booking está funcionando exactamente como se diseñó: informar, con precisión, que no existe una reserva activa con ese número. Confundir un resultado negativo válido con un error del sistema es un error de lectura, no de código.

  4. Ejecutar los cinco escenarios en el mismo proceso sin reiniciar reservo_tools. Si corres este mini-proyecto después de otro ejemplo de la guía en la misma sesión, los booking_id y el estado de BOOKINGS no van a coincidir con el "Qué esperar" — cada escenario asume un Reservo desechable, recién iniciado.

  5. Saltarse el Escenario B pensando que "no rutea" significa "no aplica al mini-proyecto". Es el escenario más importante del lote — el único que ejercita, con un caso nuevo, el mismo límite que sostiene toda la lección 07: el ruteo determinista es honesto cuando no sabe, y esa honestidad es justamente lo que hace posible un fallback simple y correcto.


Ejercicios

Ejercicio 1: Confirma tu propia ejecución (Fácil)

Ejecuta los cinco escenarios del encargo tú mismo y confirma, línea por línea, que tu salida coincide con la solución completa. Para el Escenario B, escribe en una frase adicional por qué es distinto de la petición ambigua de las lecciones 03/04/07 —a pesar de que las dos mencionan "cancelar".

Ver solución

No hay una única "solución de código" para la primera parte — es una verificación: si tu salida coincide con la de la solución completa, tu Reservo desechable arrancó limpio.

Sobre el Escenario B: la diferencia con el caso de las lecciones 03/04/07 no es el tema —los dos son preguntas sobre un cargo relacionado con cancelar—, es la redacción exacta. "Necesito cancelar porque no voy a poder llegar..." contiene la palabra "cancelar" en su forma exacta que BOOKING_KEYWORDS reconoce; "¿Cuánto me cobran si cancelo..." usa la conjugación "cancelo", que no coincide con ninguna substring de la lista. El primero falla con confianza (matchea y se equivoca); el segundo falla honestamente (no matchea nada, devuelve None). Son dos modos de falla distintos aunque el tema de fondo —una pregunta sobre cargo, disfrazada de mención a cancelar— sea el mismo.

Ejercicio 2: Agrega un sexto escenario, F (Medio)

Diseña un sexto escenario de Reservo, "¿Cuánto sale agendar el Studio pro 5h?", y ejecutalo con el mismo flujo: ruteo, guion, despacho. Nota que "agendar" contiene la keyword de BOOKING_KEYWORDS, aunque la intención real de la frase es solo cotizar, no reservar de inmediato.

Ver solución
task_f = "¿Cuánto sale agendar el Studio pro 5h?"
target_f = route_deterministic(task_f)
print(f"texto: {task_f!r}")
print(f"ruteo determinista -> {target_f}")

BOOKING_TOOLS = {
    "list_rooms": rt.list_rooms, "get_quote": rt.get_quote,
    "book_room": rt.book_room, "cancel_booking": rt.cancel_booking,
}
script_f_booking = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Studio", "tier": "pro", "hours": 5}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Studio pro 5h cuesta 16000 centavos. Avísame si quieres que la reserve."}]},
]
final_f, hist_f = run_agent_parallel(task_f, script_f_booking, BOOKING_TOOLS)
print("respuesta:", final_f["content"][0]["text"])
print("cálculo a mano Studio pro 5h:", 4000 * 5 * 80 // 100)

Salida esperada:

texto: '¿Cuánto sale agendar el Studio pro 5h?'
ruteo determinista -> booking_agent
respuesta: Studio pro 5h cuesta 16000 centavos. Avísame si quieres que la reserve.
cálculo a mano Studio pro 5h: 16000

Explicación: el ruteo a booking_agent es correcto —"agendar" sí vive en su dominio, aunque la intención puntual de esta frase sea solo cotizar—. booking_agent puede resolver preguntas de solo-cotizar sin problema, porque get_quote es una de sus cuatro tools; el guion, a diferencia del Escenario A, termina en texto después de la cotización, sin llamar a book_room — el mismo especialista, dos comportamientos distintos según qué pida la tarea real.

Ejercicio 3: Mide la accuracy del router puro sobre el encargo completo (Difícil)

Usando el mismo patrón de route_coverage de la lección 03, mide qué porcentaje de los cinco escenarios del encargo original (A a E) el router puramente determinista —sin ningún fallback— resuelve correctamente, contra la respuesta esperada de la solución completa.

Ver solución
LABELED_MINI = [
    ("Necesito reservar el Boardroom pro 2h para Marta.", "booking_agent"),
    ("¿Cuánto me cobran si cancelo con menos de 2 horas de anticipación?", "policy_agent"),
    ("Compara Studio y Boardroom, tier pro, 2 horas cada una.", "pricing_agent"),
    ("Cancela mi reserva número 7.", "booking_agent"),
    ("¿Qué política aplica para grupos corporativos grandes?", "policy_agent"),
]
correct = sum(1 for text, expected in LABELED_MINI if route_deterministic(text) == expected)
print(f"accuracy del ruteo determinista puro sobre el encargo: {correct}/{len(LABELED_MINI)}")
for text, expected in LABELED_MINI:
    got = route_deterministic(text)
    mark = "OK" if got == expected else "MISS"
    print(f"  [{mark}] esperado={expected!r:16} obtenido={got!r:16}")

Salida esperada:

accuracy del ruteo determinista puro sobre el encargo: 4/5
  [OK] esperado='booking_agent'  obtenido='booking_agent' 
  [MISS] esperado='policy_agent'   obtenido=None            
  [OK] esperado='pricing_agent'  obtenido='pricing_agent' 
  [OK] esperado='booking_agent'  obtenido='booking_agent' 
  [OK] esperado='policy_agent'   obtenido='policy_agent'

Explicación: el 80% de accuracy de este encargo (4/5) es consistente con el 80% que ya midió el Ejercicio 3 de la lección 03 sobre un set distinto — no es casualidad, es la misma proporción aproximada de peticiones "limpias" contra "necesitan fallback" que este módulo viene mostrando desde la lección 03. El único MISS es, otra vez, el Escenario B — el router determinista es honesto sobre su límite (None, no una respuesta incorrecta), y ese único caso es exactamente el que un router híbrido bien diseñado (lección 07) resolvería con una sola llamada adicional al modelo.


Resumen y siguiente paso

  • El mini-proyecto no agregó ningún concepto nuevo: aplicó las siete lecciones anteriores —anatomía del supervisor, ruteo determinista, ruteo por decisión del modelo, pricing_agent construido, el dispatcher completo, el router híbrido con sus límites— sobre cinco escenarios de Reservo nuevos.
  • Cuatro de los cinco escenarios (A, C, D, E) se resolvieron completos con el router determinista puro; el Escenario B confirmó, sobre un caso nunca visto, el mismo modo de falla honesto (None) que la lección 03 ya había caracterizado.
  • El Escenario E distinguió dos límites que se parecen pero son distintos: un ruteo correcto hacia un especialista cuya cobertura (el stub de search_docs) sigue siendo limitada.
  • El criterio completo de este módulo —anatomía de cuatro pasos, dos mecanismos de decisión, sus costos medidos, sus límites reales— es la base que el Módulo 3 va a reusar y contrastar: un pipeline no decide nada en absoluto, ni por reglas ni por el modelo — el orden está fijo desde el diseño.

Con esto termina el Módulo 2. Construiste el patrón supervisor/router completo: la anatomía de cuatro pasos, dos mecanismos de decisión —determinista y por decisión del modelo—, medidos con números reales, con un dispatcher de tres especialistas funcionando de punta a punta y un router híbrido cuyos límites conoces de primera mano. En el Módulo 3 construimos el segundo patrón: el pipeline secuencial — cuando la tarea siempre necesita los mismos pasos, en el mismo orden, sin ninguna decisión de ruteo en el medio.


Recursos adicionales

  1. Anthropic — Building effective agents — El principio de usar el mecanismo más simple que resuelva la tarea, y medir antes de sumar complejidad — el criterio detrás de todo este mini-proyecto.
  2. Anthropic — Multi-agent research system — Un caso real donde reconocer los límites de un mecanismo simple, en vez de forzarlo, determinó el diseño final del sistema.
  3. Anthropic — Agent SDK overview — Cómo se ve, en código real, un supervisor con router — el destino del Módulo 8 de esta guía.
  4. Python — Diccionarios y funciones — La estructura detrás de SPECIALISTS y route_deterministic, la base de todo el supervisor de este módulo.