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

Construyendo pricing_agent

Descripción

El Módulo 1 nombró a pricing_agent desde la lección 02: "compone get_quote para comparar el costo de varias salas". Pero en las ocho lecciones de ese módulo, pricing_agent nunca corrió como agente completo — el mini-proyecto (lección 08) lo dejó explícito: el criterio estricto de ese módulo, aplicado al escenario de comparar precios en aislamiento, no exigía un agente separado, así que nunca se ejecutó su propio run_agent_parallel. Esta lección cierra esa cuenta pendiente: acá pricing_agent corre por primera vez de punta a punta, con su propio guion, sobre el mismo runner de siempre.

Vas a ver algo que el Módulo 1 ya adelantó pero que ahora se vuelve concreto en ejecución: pricing_agent no tiene ninguna tool que booking_agent no tenga — su único registro es {"get_quote": rt.get_quote}, un subconjunto exacto del de booking_agent. Lo que lo hace un especialista real, ejecutado en esta lección, es la forma en que compone esa tool: tres llamadas a get_quote en el mismo turno, ninguna dependiente de la otra, seguidas de una comparación en texto — nunca una reserva.

Conexión con el módulo

Esta lección completa el registro SPECIALISTS de la lección 02: ahí pricing_agent ya estaba declarado, con su tools y su expertise, pero nunca se había ejecutado un guion real sobre él. Con esta lección, los tres especialistas del módulo quedan probados de punta a punta, listos para el dispatcher completo de la lección 06.


Analogía: el mismo llavero, un trabajo distinto

Retoma el llavero de agent-fundamentals M5: una sola llave, get_quote, que booking_agent usa para saber cuánto cobrar antes de reservar. pricing_agent tiene esa misma llave — literalmente la misma función de Python, rt.get_quote— pero la usa para un trabajo distinto: no abrir una puerta (reservar), sino comparar cuánto costaría abrir tres puertas distintas, sin abrir ninguna. La llave es igual; el oficio de quien la sostiene, no.


Ejemplo trabajado: pricing_agent de punta a punta

import concurrent.futures
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})")


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


# pricing_agent NO tiene una tool nueva -- reusa get_quote de booking_agent.
PRICING_TOOLS = {"get_quote": rt.get_quote}

TASK_PRICING = "Compara el precio de Focus, Studio y Boardroom, todos pro, 3h."

# Guion (concepto, claude-sonnet-5): las tres cotizaciones son independientes
# entre sí -- ninguna necesita el resultado de otra -- así que el modelo las
# pide las TRES en el mismo turno (dispatch_parallel ya sabe despachar más
# de un tool_use por turno, sin cambios, desde agent-fundamentals M5).
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."
        )}]},
]

final, history = run_agent_parallel(TASK_PRICING, model_script_pricing, PRICING_TOOLS)

print("--- historial completo de pricing_agent ---")
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}")

print()
print("respuesta final:", final["content"][0]["text"])
print("llamadas al modelo:", count_model_calls(history))
print("llamadas a tools:  ", count_tool_calls(history))

Qué esperar:

--- historial completo de pricing_agent ---
  [0] user      pregunta: 'Compara el precio de Focus, Studio y Boardroom, todos pro, 3h.'
  [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 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.
llamadas al modelo: 2
llamadas a tools:   3

Dos llamadas al modelo —el turno que pide las tres cotizaciones y el turno del texto final—, tres llamadas a tools —una por sala—, y ningún hop entre agentes todavía, porque este ejemplo corre pricing_agent en aislamiento, sin supervisor por encima. Los tres precios coinciden con la fórmula de siempre: Studio pro 3h = 4000 * 3 * 80 // 100 = 9600; Boardroom pro 3h = 8000 * 3 * 80 // 100 = 19200.


Por qué las tres cotizaciones van en el mismo turno

Compara esto con booking_agent cotizando y reservando (lección 02): ahí, book_room necesita el price_cents que get_quote todavía no había devuelto, así que las dos tool calls tenían que ir en turnos separados, en secuencia. Acá, ninguna de las tres cotizaciones de pricing_agent depende de las otras dos — Focus, Studio y Boardroom se cotizan de forma completamente independiente, así que el guion las pide todas en el mismo turno. dispatch_parallel ya sabe manejar esto sin ningún cambio: recorre los tres tool_use del turno, llama a get_quote tres veces, y arma los tres tool_result en el mismo orden en que llegaron los bloques de entrada.

Esta es, dicho sea de paso, exactamente la propiedad de "separabilidad genuina" que midió el Módulo 1, lección 06 —el resultado de una sub-tarea no depende del resultado de otra—. El Módulo 4 de esta guía retoma esta misma idea a una escala mayor: fan-out entre agentes completos, no solo entre tool calls dentro de uno solo.


La garantía técnica: pricing_agent no puede reservar nada

pricing_agent nunca reserva una sala, y esa no es solo una promesa de comportamiento del modelo —es una imposibilidad técnica. PRICING_TOOLS = {"get_quote": rt.get_quote} no incluye book_room en absoluto. Si el guion de pricing_agent intentara, por error o por una decisión mal tomada, pedir un tool_use con name="book_room", dispatch_parallel fallaría con un KeyError —la misma clase de error que viste en la lección 02 con un target inválido—, porque tools["book_room"] no existe en el registro de pricing_agent. El Ejercicio 3 de esta lección lo confirma ejecutando exactamente ese caso.

Esta es una distinción importante frente al ejemplo de search_docs del Módulo 1: ahí, el límite de policy_agent era de cobertura (el stub no conoce todas las políticas posibles). Acá, el límite de pricing_agent es de capacidad — no es que decida no reservar, es que no tiene la tool para hacerlo, sin importar qué decida el modelo.


Errores comunes

  1. Pensar que pricing_agent necesita una tool nueva para ser un especialista "de verdad". El Módulo 1, lección 02, ya lo señaló: lo que distingue a un agente no es su inventario de funciones Python, es el objetivo con el que las usa. pricing_agent compone get_quote para informar; booking_agent la usa como paso previo para transaccionar.

  2. Ejecutar el guion de pricing_agent con PRICING_TOOLS incompleto o vacío. Sin "get_quote": rt.get_quote en el diccionario, dispatch_parallel falla con KeyError en la primera tool call — el mismo tipo de error de la lección 02, ahora en un contexto distinto.

  3. Confundir "tres tool_use en el mismo turno" con el patrón de fan-out completo del Módulo 4. Esto es fan-out dentro de un solo agente —tres llamadas a la misma tool, en el mismo registro—; el Módulo 4 construye fan-out entre agentes distintos, cada uno con su propio registro y su propio historial. Son parientes, no lo mismo.

  4. Olvidar que el orden de los resultados no depende del orden de finalización de los hilos. dispatch_parallel arma la lista final con zip(tool_use_blocks, results) — el mismo mecanismo ya visto en el Módulo 1, lección 06 —, así que los tres tool_result siempre aparecen en el orden Focus/Studio/Boardroom, sin importar cuál get_quote terminó primero en la realidad.

  5. Pensar que este ejemplo prueba que multi-agente conviene para comparar precios. No — el Módulo 1, lección 08 (Escenario E), ya midió que comparar precios en aislamiento no justifica un agente separado por sí solo (1/3 señales). pricing_agent se justifica por continuidad pedagógica con el resto de esta guía —los Módulos 2 a 8 lo usan como ejemplo recurrente de composición—, no porque el criterio estricto del Módulo 1 lo exija para este caso aislado.


Ejercicios

Ejercicio 1: Verifica las anclas a mano (Fácil)

Sin ejecutar nada, calcula get_quote("Studio", "pro", 3) y get_quote("Boardroom", "pro", 3) a mano, usando la fórmula ROOM_RATE_CENTS[room] * hours * 80 // 100. Confirma tu cálculo ejecutando las dos llamadas reales.

Ver solución

Cálculo a mano: Studio pro 3h = 4000 * 3 = 12000, 12000 * 80 // 100 = 9600. Boardroom pro 3h = 8000 * 3 = 24000, 24000 * 80 // 100 = 19200.

print("Studio pro 3h:", rt.get_quote("Studio", "pro", 3))
print("Boardroom pro 3h:", rt.get_quote("Boardroom", "pro", 3))

Salida esperada:

Studio pro 3h: {'price_cents': 9600}
Boardroom pro 3h: {'price_cents': 19200}

Ambos coinciden con el cálculo a mano y con la respuesta final del ejemplo trabajado.

Ejercicio 2: Compara basic contra pro para la misma sala (Medio)

Escribe un guion nuevo para pricing_agent que compare "Focus pro 3h" contra "Focus basic 3h" —dos get_quote en el mismo turno, cada uno con un tier distinto— y ejecutalo.

Ver solución
task_ex2 = "Compara Focus pro 3h contra Focus basic 3h."
script_ex2 = [
    {"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": "Focus", "tier": "basic", "hours": 3}},
    ]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Focus pro 3h: 6000 centavos. Focus basic 3h: 7500 centavos. El tier pro ahorra 1500 centavos frente a basic."}]},
]
final_ex2, hist_ex2 = run_agent_parallel(task_ex2, script_ex2, PRICING_TOOLS)
print("respuesta:", final_ex2["content"][0]["text"])

Salida esperada:

respuesta: Focus pro 3h: 6000 centavos. Focus basic 3h: 7500 centavos. El tier pro ahorra 1500 centavos frente a basic.

Explicación: pricing_agent puede comparar cualquier combinación de sala/tier/horas, no solo las tres salas entre sí — su expertise es "comparar", no "comparar exactamente tres salas". La diferencia de 1500 centavos (7500 - 6000) es exactamente el 20% de descuento que aplica pro sobre basic, para las mismas 3 horas de Focus.

Ejercicio 3: Confirma que pricing_agent no puede reservar (Difícil)

Arma un guion donde el primer turno de pricing_agent pida un tool_use con name="book_room" (en vez de get_quote) y ejecutalo contra PRICING_TOOLS. Confirma que run_agent_parallel falla con un KeyError, y explica por qué esa falla es una garantía técnica, no una decisión de comportamiento del modelo.

Ver solución
task_ex3 = "Compara Focus pro 3h y resérvalo directamente."
script_ex3 = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "book_room",
         "input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
]
try:
    run_agent_parallel(task_ex3, script_ex3, PRICING_TOOLS)
except KeyError as e:
    print(f"KeyError capturado: {e!r} -- 'book_room' no está en PRICING_TOOLS")

Salida real:

KeyError capturado: KeyError('book_room') -- 'book_room' no está en PRICING_TOOLS

Explicación: el KeyError ocurre dentro de dispatch_parallel, en la línea tools[b["name"]] — Python busca "book_room" en el diccionario PRICING_TOOLS y no lo encuentra, sin importar qué haya "decidido" el guion. Esto confirma que la restricción no depende de que el modelo se comporte bien: aunque el guion (concepto) intentara forzar una reserva, la ejecución real fallaría antes de tocar ningún dato de Reservo. Es una garantía del registro de tools, no de la buena voluntad de la decisión — el mismo tipo de barrera técnica que separaba policy_agent de booking_agent en el Módulo 1, lección 06.


Resumen y siguiente paso

  • pricing_agent corrió por primera vez de punta a punta: dos llamadas al modelo, tres tool calls a get_quote en el mismo turno —porque ninguna de las tres cotizaciones depende de otra—, y una comparación final en texto.
  • Su registro de tools, {"get_quote": rt.get_quote}, es un subconjunto exacto del de booking_agent — lo que lo hace un especialista distinto es la composición, no una tool nueva.
  • pricing_agent no puede llamar book_room — no por decisión del modelo, sino porque esa tool no está en su registro. Es una garantía técnica, confirmada ejecutando el error.
  • Con esto, los tres especialistas de Reservo —booking_agent, policy_agent, pricing_agent— quedan probados de punta a punta y listos para el dispatcher completo.

Siguiente lección: 06 — El dispatcher ejecutado de punta a punta. La pieza central del módulo: route_deterministic (lección 03) más SPECIALISTS/run_specialist (lección 02) sobre los tres especialistas completos, ruteando tres peticiones distintas al agente correcto de cada una.


Recursos adicionales

  1. Anthropic — Tool use (function calling) overview — La forma de varios tool_use en el mismo turno, la pieza del protocolo que hace posible que pricing_agent pida las tres cotizaciones a la vez.
  2. Python — concurrent.futures — El módulo detrás de dispatch_parallel, reusado sin cambios desde agent-fundamentals M5.
  3. Anthropic — Building effective agents — El patrón "parallelization" para sub-tareas independientes, la base conceptual de por qué las tres cotizaciones van en un solo turno.
  4. Python — Diccionarios — La estructura detrás de PRICING_TOOLS, la garantía técnica de qué puede y qué no puede hacer este especialista.