Módulo 8: Project The Reservo Multi Agent System

Conectando un handoff al sistema

Descripción

Las Demos A y B resolvieron dos formas de petición distintas —orden fijo y sub-tareas independientes— pero ninguna de las dos tuvo un agente que, a mitad de trabajar, se topara con algo fuera de su expertise. Esta lección conecta la tercera pieza del sistema, run_with_handoff (Módulo 5), sin ningún cambio, y la prueba en aislado, sobre la pregunta de Valentina que la lección 08 va a usar como una de las tres sub-tareas de la Demo C.

A diferencia de las lecciones 03 y 04, acá no hay una "Demo" completa todavía — Valentina hace más de una pregunta en su petición completa (la lección 08 arma el PLAN con sus tres tracks), pero esta lección aísla solo la parte que necesita handoff, para confirmar que la pieza funciona sola antes de combinarla con las otras dos en el capstone.

Conexión con el módulo

HandoffPackage, run_agent_with_handoff y run_with_handoff son exactamente las mismas tres piezas del Módulo 5 — el HANDOFF_TOOL_NAME sigue siendo "handoff_to_specialist", y el mecanismo sigue deteniendo el loop del agente emisor en el momento exacto en que aparece esa tool, sin que el receptor sepa nada del historial completo del emisor. La lección 08 retoma esta misma pieza como el tercer Track de la Demo C, con el mismo context de transferencia que construyes acá.


Analogía: el mozo que llama al sommelier, otra vez

Es la misma escena que M5 usó desde su primera lección: un mozo (booking_agent) está tomando un pedido normal —cotizar una sala— y, a mitad de camino, el cliente pregunta algo que vive claramente fuera de su trabajo: qué pasa si alguien falta a la reserva. El mozo no vuelve a la barra a preguntarle al maître qué hacer — llama directamente al sommelier (policy_agent), le pasa lo mínimo que necesita para responder, y se retira de la conversación.


Ejemplo trabajado: Valentina cotiza el Studio, y pregunta por no-presentación a mitad de camino

import concurrent.futures
from dataclasses import dataclass, field
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 search_docs(query):
    q = query.lower()
    if "no" in q and ("present" in q or "show" in q or "llega" in q):
        return ("[no-show-policy] 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.")
    return "No se encontró una política relevante para esa pregunta."


SPECIALISTS = {
    "booking_agent": {"tools": {"get_quote": rt.get_quote}},
    "policy_agent": {"tools": {"search_docs": search_docs}},
}


# ---- M5: handoff, sin cambios ----
HANDOFF_TOOL_NAME = "handoff_to_specialist"


@dataclass
class HandoffPackage:
    sender: str
    receiver: str
    reason: str
    task: str
    context: dict = field(default_factory=dict)


def run_agent_with_handoff(question, model_script, tools, self_name, 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, None
        block = turn["content"][0]
        if block["name"] == HANDOFF_TOOL_NAME:
            inp = block["input"]
            package = HandoffPackage(
                sender=self_name, receiver=inp["receiver"], reason=inp["reason"],
                task=inp["task"], context=inp.get("context", {}),
            )
            return None, messages, package
        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 run_specialist_with_handoff(name, task, model_script):
    tools = SPECIALISTS[name]["tools"]
    return run_agent_with_handoff(task, model_script, tools, self_name=name)


def run_with_handoff(name, task, model_scripts):
    final, history, package = run_specialist_with_handoff(name, task, model_scripts[name])
    trace = [{"agent": name, "history": history, "package": package}]
    if package is None:
        return final, trace
    receiver_final, receiver_history, receiver_package = run_specialist_with_handoff(
        package.receiver, package.task, model_scripts[package.receiver],
    )
    trace.append({"agent": package.receiver, "history": receiver_history, "package": receiver_package})
    return receiver_final, trace


# ---- La pieza de handoff de Valentina, en aislado ----
print("--- Valentina: cotiza el Studio pro 4h para el taller, y pregunta por no-presentación a mitad de camino ---")

script_workshop_booking = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Studio", "tier": "pro", "hours": 4}}]},
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_02", "name": HANDOFF_TOOL_NAME,
         "input": {
             "receiver": "policy_agent",
             "reason": "la pregunta de no-presentación es política, fuera de mi expertise",
             "task": "¿qué pasa si un miembro no se presenta a una reserva confirmada?",
             "context": {"room": "Studio", "tier": "pro", "hours": 4, "price_cents": 12800},
         }}]},
]
script_workshop_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_scripts_workshop = {"booking_agent": script_workshop_booking, "policy_agent": script_workshop_policy}

final, trace = run_with_handoff(
    "booking_agent", "Cotiza Studio pro 4h. ¿Qué pasa si no llego?", model_scripts_workshop,
)
print(f"{trace[0]['agent']} cedió el turno a {trace[0]['package'].receiver}")
print("razón del handoff:", trace[0]["package"].reason)
print("respuesta final:", final["content"][0]["text"])

ctx = trace[0]["package"].context
print("contexto transferido:", ctx)
print("cargo de no-presentación (calculado a partir del context, no de policy_agent):", ctx["price_cents"] // 2)
print()
print("reservas en BOOKINGS tras esta corrida (Valentina solo cotizó, nunca reservó):", len(rt.BOOKINGS))

Qué esperar:

--- Valentina: cotiza el Studio pro 4h para el taller, y pregunta por no-presentación a mitad de camino ---
booking_agent cedió el turno a policy_agent
razón del handoff: la pregunta de no-presentación es política, fuera de mi expertise
respuesta 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.
contexto transferido: {'room': 'Studio', 'tier': 'pro', 'hours': 4, 'price_cents': 12800}
cargo de no-presentación (calculado a partir del context, no de policy_agent): 6400

reservas en BOOKINGS tras esta corrida (Valentina solo cotizó, nunca reservó): 0

Dos números para verificar a mano: Studio pro 4h = 4000 * 4 * 80 // 100 = 12800 centavos (la cotización de booking_agent, ANTES del handoff), y el cargo de no-presentación 12800 // 2 = 6400 centavos — calculado a partir del context que viajó en el HandoffPackage, no de ningún cálculo dentro de policy_agent (que no tiene get_quote en su registro de tools, y no lo necesita: el precio ya viene resuelto en el paquete). BOOKINGS sigue en 0 porque esta pieza aislada nunca llama a book_room — Valentina, en esta parte de su petición, solo cotiza y pregunta, no reserva.


Qué va en el context, y por qué

Fíjate en la forma exacta del context que booking_agent le pasa a policy_agent:

context = {"room": "Studio", "tier": "pro", "hours": 4, "price_cents": 12800}

No incluye el historial completo de booking_agent (la pregunta original, el tool_use de get_quote, el tool_result crudo) — solo los cuatro datos que policy_agent realmente necesita para calcular el cargo de no-presentación: qué sala, qué tier, cuántas horas, y el precio ya calculado. Es exactamente la disciplina que M5 L03 (the-handoff-package-what-goes-in-what-stays-out) estableció: pasar lo mínimo necesario, nunca el historial completo — evitar el "context bloat" que un handoff mal diseñado podría acumular si cada transferencia arrastrara toda la conversación previa.


Por qué esta pieza es "handoff" y no "fan-out con dos preguntas sueltas"

1. ¿"qué pasa si no llego" depende del resultado de otra sub-tarea?
   SÍ, pero el orden se DESCUBRE a mitad de resolver la cotización -- nadie
   planeó de antemano que booking_agent necesitaría ceder el turno.
   -> HANDOFF (no pipeline, porque el orden no se conocía de antemano).

La diferencia con la Demo B de la lección 04 es sutil pero real: las dos preguntas de Marta ya nacían, cada una, en el dominio del especialista correcto — nadie tuvo que "descubrir" nada a mitad de camino. Acá, en cambio, booking_agent empieza trabajando la cotización (su propio dominio) y solo al llegar a la segunda parte de la frase de Valentina se topa con una pregunta que no le corresponde. Ese momento de descubrimiento, a mitad de una tarea que no lo anticipaba, es exactamente lo que distingue un handoff de un fan-out plano — la misma distinción que M7 L02 estableció para la petición de Ana.


Errores comunes

  1. Pensar que policy_agent recibe el historial completo de booking_agent. No lo recibe — solo recibe package.task (la pregunta reformulada) y package.context (los cuatro datos mínimos). policy_agent no sabe, ni necesita saber, que hubo una llamada a get_quote antes de que le llegara el turno.

  2. Calcular el cargo de no-presentación dentro de policy_agent. policy_agent no tiene get_quote en su registro de tools en esta lección — el cálculo final (price_cents // 2) ocurre DESPUÉS de que el handoff termina, usando el context ya transferido, no una nueva consulta de precio.

  3. Confundir "Valentina cotiza el Studio pro 4h" con una reserva real. Esta pieza aislada nunca llama a book_room — es la parte de la petición de Valentina que la lección 08 SÍ va a combinar con una reserva real (la de Focus, en un track de pipeline distinto), pero esta sub-tarea en particular termina en una cotización, no en una confirmación.


Ejercicios

Ejercicio 1: Repite el handoff con otra sala y otra hora (Fácil)

Repite la pieza completa de esta lección cotizando "Boardroom pro 3h" en vez de "Studio pro 4h" (Boardroom pro 3h = 8000 * 3 * 80 // 100 = 19200 centavos).

Ver solución
script_boardroom_booking = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Boardroom", "tier": "pro", "hours": 3}}]},
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_02", "name": HANDOFF_TOOL_NAME,
         "input": {
             "receiver": "policy_agent",
             "reason": "la pregunta de no-presentación es política, fuera de mi expertise",
             "task": "¿qué pasa si un miembro no se presenta a una reserva confirmada?",
             "context": {"room": "Boardroom", "tier": "pro", "hours": 3, "price_cents": 19200},
         }}]},
]
model_scripts_boardroom = {"booking_agent": script_boardroom_booking, "policy_agent": script_workshop_policy}
final_br, trace_br = run_with_handoff("booking_agent", "Cotiza Boardroom pro 3h. ¿Qué pasa si no llego?", model_scripts_boardroom)
ctx_br = trace_br[0]["package"].context
print("cargo de no-presentación:", ctx_br["price_cents"] // 2)

Salida esperada:

cargo de no-presentación: 9600

Explicación: 19200 // 2 = 9600 — el mismo cálculo, con una cotización más alta porque Boardroom es la sala más cara de las tres.

Ejercicio 2: ¿Qué pasaría si context no incluyera price_cents? (Medio)

Sin ejecutar código todavía, predice qué pasaría si el context del handoff SOLO tuviera {"room": "Studio", "tier": "pro", "hours": 4}, sin price_cents. Después, verifica ejecutando el cálculo del cargo de no-presentación con ese context incompleto.

Ver solución
ctx_incompleto = {"room": "Studio", "tier": "pro", "hours": 4}
try:
    print(ctx_incompleto["price_cents"] // 2)
except KeyError as e:
    print(f"KeyError: {e}")

Salida esperada:

KeyError: 'price_cents'

Explicación: exactamente lo predicho — sin price_cents en el context, el cálculo del cargo de no-presentación no tiene de dónde sacar el precio, porque policy_agent (por diseño) no tiene get_quote en su registro y no puede recalcularlo por su cuenta. Esto confirma, con un error real, por qué M5 L03 insiste en que el context tiene que incluir todo lo que el receptor necesita para completar su parte de la tarea — omitir un solo campo rompe la corrida en el punto exacto donde se necesita, no antes.

Ejercicio 3: ¿Por qué el handoff de esta lección tiene 1 hop, no 2? (Difícil)

Sin ejecutar coordination_cost todavía (se construye en la lección 07), explica —usando la convención de M5 L07— por qué la transferencia de booking_agent a policy_agent de esta lección cuenta como 1 hop, y no 2 (uno de ida, uno de vuelta), como sí ocurre cuando el supervisor consulta a un especialista en un fan-out.

Ver solución

Porque un handoff es, por definición, una transferencia directa en una sola dirección — booking_agent no espera a que policy_agent le devuelva el control a ÉL; policy_agent responde directamente al socio, y ahí termina la corrida (run_with_handoff retorna el resultado del receptor, no vuelve a pasar por el emisor). Un fan-out consultado por un supervisor, en cambio, SÍ tiene una ida y una vuelta reales: el supervisor despacha la tarea (hop 1) y el especialista le devuelve el resultado a él para que lo incluya en la respuesta compuesta (hop 2) — el supervisor sigue "en el medio" de la conversación. En un handoff no hay nadie "en el medio" después de la transferencia: por eso M5 L07 midió hops_a = 1 para el camino con handoff directo, contra 3 hops para el camino sin handoff (que sí necesitaba volver al supervisor dos veces). La lección 07 de este módulo reusa exactamente esta misma convención al construir coordination_cost.


Resumen y siguiente paso

  • run_with_handoff, sin ningún cambio desde el Módulo 5, resolvió la pieza de handoff que la Demo C va a necesitar: booking_agent cotiza el Studio pro 4h, se topa con una pregunta de no-presentación, y cede el turno directamente a policy_agent.
  • El context transferido lleva solo lo mínimo (room, tier, hours, price_cents) — nunca el historial completo de la conversación previa.
  • 12800 centavos de cotización, 6400 de cargo de no-presentación — ambos verificados a mano y confirmados en la salida real.

Siguiente lección: 06 — Un blackboard para todo el sistema. Confirmamos, sobre las tres piezas ya conectadas (Luis, Marta, Valentina), quién escribe al estado compartido y quién no.


Recursos adicionales

  1. Anthropic — Multi-agent research system — Sub-agentes que transfieren trabajo directamente entre sí, sin volver siempre a un coordinador central — la forma general de un handoff.
  2. Anthropic — Messages API reference — La forma exacta de tool_use que representa la decisión de ceder el turno (handoff_to_specialist), sin cambios desde el Módulo 5.
  3. Python — Dataclasses — La estructura detrás de HandoffPackage, con su context: dict de campo por defecto vacío.
  4. Python — Manejo de excepciones — El KeyError que el Ejercicio 2 de esta lección confirma cuando el context de un handoff llega incompleto.