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

El dispatcher ejecutado de punta a punta

Descripción

Las cinco lecciones anteriores construyeron cada pieza por separado: la anatomía de un supervisor (02), el ruteo determinista (03), el ruteo por decisión del modelo (04), y el tercer especialista completo (05). Esta lección las junta todas sobre un caso real, de punta a punta: tres peticiones distintas, cada una con una intención distinta, ruteadas por route_deterministic al especialista correcto, despachadas con run_specialist, y con el costo de coordinación de cada camino contado y citado.

No hay ninguna pieza nueva en esta lección — es la primera vez que ves el supervisor completo del Módulo 2 funcionando de principio a fin, con las tres peticiones del ejemplo trabajado de lecciones anteriores corriendo, una tras otra, contra el registro completo de especialistas.

Conexión con el módulo

Esta lección es la síntesis ejecutada de las lecciones 02 a 05: usa SPECIALISTS y run_specialist (02), route_deterministic (03) sin ningún cambio, y el pricing_agent recién construido (05). No usa el ruteo por decisión del modelo (04) — las tres peticiones de esta lección son de intención clara, exactamente el caso donde las reglas alcanzan solas. La lección 07 retoma el caso en que no alcanzan, y construye el router híbrido que combina los dos mecanismos.


Analogía: la recepción, un día completo de trabajo

Las lecciones anteriores mostraron piezas sueltas de la recepción: el cartel de reglas, un caso donde confundió al visitante, un puesto nuevo recién habilitado. Esta lección es la recepción funcionando un día entero: tres visitantes distintos llegan, cada uno con una necesidad distinta, y el cartel de reglas los dirige a los tres pisos correctos, sin que nadie tenga que intervenir.


Ejemplo trabajado: el dispatcher completo

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})")


def count_model_calls(history):
    return sum(1 for m in history if m["role"] == "assistant")


def count_tool_calls(history):
    total = 0
    for m in history:
        if isinstance(m["content"], list):
            total += sum(1 for b in m["content"] if b["type"] == "tool_use")
    return total


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


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)."
        )}]},
]

model_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."
        )}]},
]

model_script_pricing = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Focus", "tier": "pro", "hours": 3}},
        {"type": "tool_use", "id": "toolu_02", "name": "get_quote",
         "input": {"room": "Studio", "tier": "pro", "hours": 3}},
        {"type": "tool_use", "id": "toolu_03", "name": "get_quote",
         "input": {"room": "Boardroom", "tier": "pro", "hours": 3}},
    ]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": (
            "Focus pro 3h: 6000 centavos. Studio pro 3h: 9600 centavos. "
            "Boardroom pro 3h: 19200 centavos. Focus es la opción más "
            "barata de las tres."
        )}]},
]

REQUESTS = [
    ("Cotiza Focus pro 3h y resérvala para Ana.", model_script_booking),
    ("¿Qué pasa si no me presento a mi reserva?", model_script_policy),
    ("Compara el precio de Focus, Studio y Boardroom, todos pro, 3h.", model_script_pricing),
]

ROUTE_CALLS = 0     # determinista: cero llamadas al modelo para rutear
COMPOSE_CALLS = 1   # concepto: el supervisor sintetiza la respuesta final

summary = []
for text, script in REQUESTS:
    target = route_deterministic(text)
    print(f"petición: {text!r}")
    print(f"  ruteo determinista (0 llamadas al modelo) -> {target}")
    final, history = run_specialist(target, text, script)
    for i, m in enumerate(history):
        role, content = m["role"], m["content"]
        if isinstance(content, str):
            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}")
    calls = count_model_calls(history)
    tools_ = count_tool_calls(history)
    total_calls = ROUTE_CALLS + calls + COMPOSE_CALLS
    print(f"  respuesta ({target}): {final['content'][0]['text']}")
    print(f"  llamadas al modelo: {total_calls} ({ROUTE_CALLS} ruteo + {calls} {target} + {COMPOSE_CALLS} síntesis)")
    print(f"  llamadas a tools:   {tools_}")
    print(f"  hops entre agentes: 2")
    print()
    summary.append((text, target, total_calls, tools_, 2))

print("--- resumen ---")
labels = ["cotiza + reserva Focus pro 3h", "política de no-presentación", "compara Focus/Studio/Boardroom"]
print(f"{'petición':32}{'agente':16}{'modelo':>8}{'tools':>7}{'hops':>6}")
for (text, target, calls, tools_, hops), label in zip(summary, labels):
    print(f"{label:32}{target:16}{calls:>8}{tools_:>7}{hops:>6}")

Qué esperar:

petición: 'Cotiza Focus pro 3h y resérvala para Ana.'
  ruteo determinista (0 llamadas al modelo) -> booking_agent
    [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).'
  respuesta (booking_agent): Focus pro 3h cuesta 6000 centavos. Reservé la sala para Ana (confirmación #1).
  llamadas al modelo: 4 (0 ruteo + 3 booking_agent + 1 síntesis)
  llamadas a tools:   2
  hops entre agentes: 2

petición: '¿Qué pasa si no me presento a mi reserva?'
  ruteo determinista (0 llamadas al modelo) -> policy_agent
    [1] assistant tool_use(search_docs): {'query': 'qué pasa si no me presento a mi reserva'}
    [2] user      tool_result: [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.
    [3] assistant texto final: '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.'
  respuesta (policy_agent): 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.
  llamadas al modelo: 3 (0 ruteo + 2 policy_agent + 1 síntesis)
  llamadas a tools:   1
  hops entre agentes: 2

petición: 'Compara el precio de Focus, Studio y Boardroom, todos pro, 3h.'
  ruteo determinista (0 llamadas al modelo) -> pricing_agent
    [1] assistant tool_use(get_quote): {'room': 'Focus', 'tier': 'pro', 'hours': 3}
    [1] assistant tool_use(get_quote): {'room': 'Studio', 'tier': 'pro', 'hours': 3}
    [1] assistant tool_use(get_quote): {'room': 'Boardroom', 'tier': 'pro', 'hours': 3}
    [2] user      tool_result: {'price_cents': 6000}
    [2] user      tool_result: {'price_cents': 9600}
    [2] user      tool_result: {'price_cents': 19200}
    [3] assistant texto final: 'Focus pro 3h: 6000 centavos. Studio pro 3h: 9600 centavos. Boardroom pro 3h: 19200 centavos. Focus es la opción más barata de las tres.'
  respuesta (pricing_agent): Focus pro 3h: 6000 centavos. Studio pro 3h: 9600 centavos. Boardroom pro 3h: 19200 centavos. Focus es la opción más barata de las tres.
  llamadas al modelo: 3 (0 ruteo + 2 pricing_agent + 1 síntesis)
  llamadas a tools:   3
  hops entre agentes: 2

--- resumen ---
petición                        agente            modelo  tools  hops
cotiza + reserva Focus pro 3h   booking_agent          4      2     2
política de no-presentación     policy_agent           3      1     2
compara Focus/Studio/Boardroom  pricing_agent          3      3     2

Tres peticiones, tres agentes distintos, tres rutas correctas — todas decididas sin gastar una sola llamada al modelo en el Paso 2 (decidir). El único costo de coordinación en este dispatcher es la síntesis final (COMPOSE_CALLS = 1, concepto) más el trabajo interno de cada especialista, que varía según cuántos pasos necesita: 3 para booking_agent (cotizar, reservar, responder), 2 para policy_agent y pricing_agent (un turno de tools, un turno de texto).


El ahorro concreto: compara con el Módulo 1, lección 05

El Módulo 1, lección 05, midió el mismo tipo de tarea —"Cotiza Focus pro 3h y resérvala para Ana"— con un supervisor que siempre ruteaba por decisión del modelo, porque solo existía un especialista posible:

M1 L05 (ruteo SIEMPRE por decisión del modelo): 1 ruteo + 3 booking_agent + 1 síntesis = 5 llamadas
M2 L06 (ruteo determinista, esta misma tarea):  0 ruteo + 3 booking_agent + 1 síntesis = 4 llamadas

Una llamada menos, exactamente el costo de rutear, ahorrado — sin perder nada de exactitud, porque la tarea es de intención clara y las reglas la resuelven perfecto. Esta es la primera vez en la guía que un router determinista se compara, número contra número, con un router por decisión del modelo sobre la misma tarea exacta. El resultado confirma lo que la lección 03 ya adelantaba: cuando las reglas alcanzan, cuestan menos sin perder nada.


Errores comunes

  1. Pensar que el dispatcher de esta lección maneja cualquier petición. Solo maneja las que route_deterministic sabe rutear — una petición como la del "wifi" de la lección 03 rompería este dispatcher tal como está, porque SPECIALISTS[None] no existe. La lección 07 lo resuelve.

  2. Correr las tres peticiones en el mismo proceso sin reiniciar el estado entre lecciones. Si ejecutas el código de esta lección después de haber corrido otro ejemplo de la guía en el mismo intérprete, book_room puede devolver un booking_id distinto a 1 — un desajuste real con el texto del guion, no un error del dispatcher. Cada lección asume su propio Reservo desechable, recién iniciado.

  3. Sumar mal el costo total. ROUTE_CALLS + calls + COMPOSE_CALLS no es "cuántas tool calls hizo el especialista" — mezclar esos dos números lleva a comparaciones erróneas entre peticiones. Este dispatcher los reporta por separado a propósito (llamadas al modelo vs. llamadas a tools).

  4. Generalizar el ahorro de "1 llamada menos" a cualquier volumen de tráfico. Esta comparación es para una petición. La lección 07 escala el cálculo a un volumen diario real, donde el ahorro puede ser mucho mayor —o, si la mayoría de las peticiones son ambiguas, mucho menor.

  5. Olvidar que estos tres agentes siguen corriendo, cada uno, el mismo runner sin cambios. Nada en booking_agent, policy_agent ni pricing_agent sabe que está siendo ruteado por un supervisor — cada uno simplemente recibe una tarea y un guion, y ejecuta su loop de siempre. La novedad de este módulo vive enteramente en lo que rodea a cada agente, no dentro de ellos.


Ejercicios

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

Ejecuta el dispatcher completo de esta lección tú mismo y confirma, línea por línea, que tu salida coincide con el "Qué esperar" de arriba. Presta especial atención a los tres booking_id/precios y a los totales de llamadas de la tabla resumen.

Ver solución

No hay una única "solución de código" para este ejercicio — es una verificación: si tu salida coincide exactamente con el bloque "Qué esperar" del ejemplo trabajado, tu Reservo desechable arrancó limpio y el dispatcher corrió sin desvíos.

Ejercicio 2: ¿Qué pasa con una petición que no rutea? (Medio)

Ejecuta route_deterministic("Hola, ¿tienen wifi en las salas?") y explica, sin ejecutar el dispatcher completo sobre esa petición, qué pasaría si intentaras despacharla tal como está escrito el código de esta lección.

Ver solución
task_none = "Hola, ¿tienen wifi en las salas?"
target_none = route_deterministic(task_none)
print(f"route_deterministic({task_none!r}) -> {target_none!r}")

Salida esperada:

route_deterministic('Hola, ¿tienen wifi en las salas?') -> None

Explicación: el dispatcher de esta lección no tiene ningún caso para target = None — intentar run_specialist(None, ...) fallaría con un KeyError, porque SPECIALISTS[None] no existe en el registro. Este dispatcher, tal como está construido acá, asume implícitamente que route_deterministic siempre encuentra un especialista — una suposición que la lección 07 corrige con el router híbrido.

Ejercicio 3: El mismo dispatcher, otra sala y otras horas (Difícil)

Agrega una cuarta petición al dispatcher: "Cotiza Boardroom pro 4h y resérvala para Sofía." con su guion correspondiente. Ejecutala a través del mismo flujo de ruteo + despacho + conteo, y confirma el precio calculado a mano.

Ver solución
task_ex3 = "Cotiza Boardroom pro 4h y resérvala para Sofía."
script_ex3 = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Boardroom", "tier": "pro", "hours": 4}}]},
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_02", "name": "book_room",
         "input": {"room": "Boardroom", "tier": "pro", "hours": 4, "member": "Sofía"}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Boardroom pro 4h cuesta 25600 centavos. Reservé la sala para Sofía (confirmación #1)."}]},
]
target_ex3 = route_deterministic(task_ex3)
final_ex3, hist_ex3 = run_specialist(target_ex3, task_ex3, script_ex3)
calls_ex3 = count_model_calls(hist_ex3)
tools_ex3 = count_tool_calls(hist_ex3)
print(f"ruteo -> {target_ex3}")
print("respuesta:", final_ex3["content"][0]["text"])
print(f"llamadas al modelo: {0 + calls_ex3 + 1} (0 ruteo + {calls_ex3} {target_ex3} + 1 síntesis)")
print(f"llamadas a tools: {tools_ex3}")
print("cálculo a mano Boardroom pro 4h:", 8000 * 4 * 80 // 100)

Salida esperada:

ruteo -> booking_agent
respuesta: Boardroom pro 4h cuesta 25600 centavos. Reservé la sala para Sofía (confirmación #1).
llamadas al modelo: 4 (0 ruteo + 3 booking_agent + 1 síntesis)
llamadas a tools: 2
cálculo a mano Boardroom pro 4h: 25600

Explicación: la estructura de costo es idéntica a la de "Cotiza Focus pro 3h" del ejemplo trabajado —4 llamadas al modelo, 2 tool calls— porque el número de pasos internos de booking_agent (cotizar, reservar, responder) no depende de qué sala ni de cuántas horas se pidan, solo de la forma de la tarea. 25600 = 8000 * 4 * 80 // 100 confirma el precio.


Resumen y siguiente paso

  • Ejecutamos el dispatcher completo del Módulo 2: tres peticiones, cada una ruteada por route_deterministic, despachada con run_specialist, sobre los tres especialistas terminados de las lecciones anteriores.
  • Los números reales: booking_agent (cotizar + reservar) costó 4 llamadas al modelo y 2 tool calls; policy_agent y pricing_agent costaron 3 llamadas al modelo cada uno, con 1 y 3 tool calls respectivamente. Las tres, 0 llamadas al modelo para rutear.
  • Comparado con el supervisor del Módulo 1, lección 05 —que siempre ruteaba por decisión del modelo— este dispatcher ahorra 1 llamada exacta en la misma tarea de reservar, sin perder nada de exactitud.
  • El dispatcher de esta lección asume que route_deterministic siempre encuentra un especialista — una petición que devuelve None lo rompe. La lección 07 lo arregla.

Siguiente lección: 07 — Eligiendo tu router. Comparamos determinista y decisión del modelo con números, y construimos un router híbrido — con sus límites reales, no idealizados.


Recursos adicionales

  1. Anthropic — Multi-agent research system — Un orquestador real decidiendo, sin llamadas de más, a qué sub-agente delegar cada parte de una tarea — la misma disciplina de costo medido de esta lección.
  2. Anthropic — Building effective agents — El principio de usar el mecanismo más simple que resuelva la tarea, antes de sumar complejidad — el resultado de esta lección lo confirma con números.
  3. Anthropic — Messages API reference — La forma exacta de tool_use/tool_result/stop_reason que cada especialista de este dispatcher respeta, sin cambios.
  4. Python — concurrent.futures — El módulo detrás de dispatch_parallel, corriendo sin cambios dentro de cada uno de los tres especialistas.