Módulo 5: Handoff y delegación

El handoff de Reservo, ejecutado de punta a punta

Descripción

Las tres lecciones anteriores construyeron cada pieza por separado: el paquete mínimo (03) y el mecanismo que lo produce (04). Esta lección las junta sobre el caso completo del módulo: run_with_ handoff, el orquestador que corre a booking_agent, reconoce si cedió el turno, y si lo hizo, despacha automáticamente a policy_agent con solo el paquete —nunca el historial del emisor—. El resultado final combina lo que los dos agentes produjeron, grounded en un número que solo existe porque viajó en el context del handoff: el cargo exacto de no-presentación para esta reserva puntual, no la política genérica.

No hay ninguna pieza nueva de bajo nivel en esta lección — es la primera vez que ves el handoff completo de Reservo funcionando de principio a fin, con el historial de los dos agentes impreso y la respuesta final citada.

Conexión con el módulo

Esta lección reusa, sin cambios, HandoffPackage (03) y run_specialist_with_handoff (04). Lo único nuevo es run_with_handoff, el orquestador de dos líneas que encadena las dos llamadas, y la composición final que usa el context del paquete para grounding. La lección 06 toma este mismo mecanismo y le agrega guardas contra los casos donde algo sale mal; la lección 07 cuenta su costo.


Analogía: el sommelier, con la nota del mesero en la mano

Retoma la escena completa de la lección 01: el sommelier llega a la mesa con la nota del mesero —"la mesa 12 pidió el salmón"—, no con la libreta completa de la noche. Con ese único dato, puede recomendar un vino que maride específicamente con salmón, no una recomendación genérica de "un vino blanco cualquiera". Esta lección es exactamente esa escena: policy_agent recibe el paquete de booking_agent, y con el price_cents que ese paquete trae, puede responder con un cargo exacto para esta reserva, no solo con el porcentaje genérico de la política.


Ejemplo trabajado: el handoff completo

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)
    ]


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,
    }},
    "policy_agent": {"tools": {"search_docs": search_docs}},
    "pricing_agent": {"tools": {"get_quote": rt.get_quote}},
}

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):
    """El orquestador completo: corre `name` sobre `task`. Si cede el
    turno, despacha AUTOMÁTICAMENTE al receptor -- con SOLO el paquete
    (package.task + package.context), nunca con el historial del emisor.
    `model_scripts` es un dict keyed por nombre de agente."""
    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


def print_history(history):
    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}")


COMPOUND_REQUEST = "Cotiza Focus pro 3h. Y otra cosa, ¿qué pasa si no llego a la reserva?"

# Guion (concepto, claude-sonnet-5) de booking_agent: cotiza, y al toparse
# con la pregunta de no-presentación, cede el turno con el contexto mínimo.
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": HANDOFF_TOOL_NAME,
         "input": {
             "receiver": "policy_agent",
             "reason": "la segunda pregunta es sobre la política de no-presentación, fuera de mi expertise",
             "task": "¿qué pasa si un miembro no se presenta a una reserva confirmada?",
             "context": {"room": "Focus", "tier": "pro", "hours": 3, "price_cents": 6000},
         }}]},
]

# Guion (concepto, claude-sonnet-5) de policy_agent: responde con la
# política GENERAL -- todavía no sabe nada de ESTA reserva puntual.
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_scripts = {"booking_agent": model_script_booking, "policy_agent": model_script_policy}

final, trace = run_with_handoff("booking_agent", COMPOUND_REQUEST, model_scripts)

for step in trace:
    print(f"--- {step['agent']} ---")
    print_history(step["history"])
    if step["package"] is not None:
        print(f"  handoff -> {step['package'].receiver} | task={step['package'].task!r} | context={step['package'].context}")
    print()

# Paso final (concepto, pero grounded): policy_agent respondió con la
# política GENERAL; personalizamos con el price_cents que viajó en el
# paquete -- un dato que policy_agent nunca calculó por sí mismo.
package = trace[0]["package"]
no_show_charge = package.context["price_cents"] // 2

final_response = (
    f"Para tu reserva de {package.context['room']} {package.context['tier']} "
    f"{package.context['hours']}h ({package.context['price_cents']} centavos): "
    f"{final['content'][0]['text']} Para esta reserva puntual, ese cargo sería "
    f"de {no_show_charge} centavos."
)
print("--- respuesta final compuesta (concepto, grounded en el paquete del handoff) ---")
print(final_response)

Qué esperar (sobre un Reservo desechable, recién iniciado):

--- booking_agent ---
  [0] user      pregunta: 'Cotiza Focus pro 3h. Y otra cosa, ¿qué pasa si no llego a la reserva?'
  [1] assistant tool_use(get_quote): {'room': 'Focus', 'tier': 'pro', 'hours': 3}
  [2] user      tool_result: {'price_cents': 6000}
  [3] assistant tool_use(handoff_to_specialist): {'receiver': 'policy_agent', 'reason': 'la segunda pregunta es sobre la política de no-presentación, fuera de mi expertise', 'task': '¿qué pasa si un miembro no se presenta a una reserva confirmada?', 'context': {'room': 'Focus', 'tier': 'pro', 'hours': 3, 'price_cents': 6000}}
  handoff -> policy_agent | task='¿qué pasa si un miembro no se presenta a una reserva confirmada?' | context={'room': 'Focus', 'tier': 'pro', 'hours': 3, 'price_cents': 6000}

--- policy_agent ---
  [0] user      pregunta: '¿qué pasa si un miembro no se presenta a una reserva confirmada?'
  [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 final compuesta (concepto, grounded en el paquete del handoff) ---
Para tu reserva de Focus pro 3h (6000 centavos): 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. Para esta reserva puntual, ese cargo sería de 3000 centavos.

Fíjate en algo importante: policy_agent nunca vio la petición original completa, ni el get_quote que corrió booking_agent, ni ningún tool_result con 6000. Recibió, únicamente, la task ("¿qué pasa si un miembro no se presenta...") y el context (price_cents=6000, entre otros campos). Con eso, search_docs le devolvió la política general —el 50%, sin ningún número concreto—. El cargo específico de 3000 centavos —el que de verdad le importa al miembro— nunca lo calculó policy_agent: se calculó afuera, en la composición final, usando exactamente el dato que viajó en el paquete. Sin ese context, esa personalización sería imposible — policy_agent no tiene ninguna forma de saber cuánto costaba la reserva de este miembro en particular.


Por qué booking_agent no compone la respuesta final

Nota una decisión de diseño de este orquestador: run_with_handoff no vuelve a booking_agent después de que policy_agent responde. La respuesta final la compone quien tiene el package a mano en ese momento —en este ejemplo, el código que llama a run_with_handoff, usando trace[0]["package"].context—, no un tercer viaje de vuelta al emisor original. Esto es a propósito: un handoff cede el turno, no lo presta — booking_agent ya terminó su parte del trabajo (cotizar) en el momento en que decidió transferir; pedirle que vuelva a intervenir después de policy_agent sería, en la práctica, un patrón distinto —más parecido a un pipeline (M3), con una etapa que depende de la anterior— y agregaría una llamada al modelo que el handoff directo no necesita. La lección 07 mide exactamente esa diferencia con números.


Errores comunes

  1. Intentar acceder a final["content"][0]["text"] sin revisar package is None primero. Cuando booking_agent cede el turno, su propio final es None — el texto que sí importa está en el final del receptor, que es lo que run_with_handoff devuelve. Confundir los dos final (el del emisor, casi siempre None, con el que devuelve la función) es el error más común al leer este ejemplo por primera vez.

  2. Pensar que policy_agent "sabe" que la reserva cuesta 6000 centavos. No sabe nada por sí mismo — solo tiene lo que search_docs le devolvió (la política general) y lo que su propio task decía. El número 6000 (y el 3000 derivado) llegan enteramente desde package.context, afuera de policy_agent.

  3. Correr esta lección en un proceso que ya tenía reservas. Aunque este ejemplo no llama a book_room —solo get_quote, que no toca BOOKINGS—, si ejecutas este código después de otro ejemplo de la guía que sí reservó algo en el mismo intérprete, el resultado de get_quote no cambia (no depende de BOOKINGS), pero es buena práctica seguir arrancando cada demo en un proceso nuevo, como el resto de la guía.

  4. Olvidar que run_with_handoff solo maneja UN handoff. Si policy_agent también intentara ceder el turno (por ejemplo, de vuelta a booking_agent), esta versión del orquestador no lo detectaría — receiver_package se calcula pero nunca se revisa. La lección 06 corrige exactamente esto con una guarda explícita.


Ejercicios

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

Ejecuta el handoff 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 al cargo final de 3000 centavos y confirma que 50% de 6000 = 3000 a mano.

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, y 6000 // 2 == 3000, tu Reservo desechable arrancó limpio y el handoff corrió sin desvíos.

Ejercicio 2: El mismo handoff, con Boardroom pro 4h (Medio)

Repite el handoff completo con "Cotiza Boardroom pro 4h. ¿Qué pasa si no llego?", ajustando context y los guiones correspondientes. Confirma el precio (8000 * 4 * 80 // 100) y el cargo de no-presentación derivado.

Ver solución
task_br = "Cotiza Boardroom pro 4h. ¿Qué pasa si no llego?"
script_booking_br = [
    {"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": HANDOFF_TOOL_NAME,
         "input": {"receiver": "policy_agent",
                    "reason": "pregunta de no-presentación, fuera de mi expertise",
                    "task": "¿qué pasa si un miembro no se presenta a una reserva confirmada?",
                    "context": {"room": "Boardroom", "tier": "pro", "hours": 4, "price_cents": 25600}}}]},
]
model_scripts_br = {"booking_agent": script_booking_br, "policy_agent": model_script_policy}
final_br, trace_br = run_with_handoff("booking_agent", task_br, model_scripts_br)
package_br = trace_br[0]["package"]
charge_br = package_br.context["price_cents"] // 2
print("precio calculado a mano:", 8000 * 4 * 80 // 100)
print("cargo de no-presentación:", charge_br)

Salida esperada:

precio calculado a mano: 25600
cargo de no-presentación: 12800

Explicación: el mismo model_script_policy del ejemplo trabajado sirve sin cambios —la pregunta que arma booking_agent sigue conteniendo "no se presenta", la keyword que search_docs reconoce—. El cargo (12800 = 25600 // 2) confirma que la personalización funciona con cualquier precio, no solo con 6000, porque el cálculo depende enteramente del price_cents que viajó en el context, no de un valor fijo.

Ejercicio 3: ¿Qué pasa si el context llega vacío? (Difícil)

Repite el handoff completo, pero con context={} en el turno de booking_agent (como si el emisor hubiera olvidado incluir el precio). Ejecuta el mismo código de composición final y explica qué pasa exactamente, y en qué línea.

Ver solución
script_booking_empty_context = [
    {"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": HANDOFF_TOOL_NAME,
         "input": {"receiver": "policy_agent",
                    "reason": "pregunta de no-presentación",
                    "task": "¿qué pasa si un miembro no se presenta a una reserva confirmada?",
                    "context": {}}}]},
]
model_scripts_empty = {"booking_agent": script_booking_empty_context, "policy_agent": model_script_policy}
final_empty, trace_empty = run_with_handoff("booking_agent", COMPOUND_REQUEST, model_scripts_empty)
package_empty = trace_empty[0]["package"]
try:
    no_show_charge_empty = package_empty.context["price_cents"] // 2
except KeyError as e:
    print(f"KeyError capturado: {e!r}")

Salida esperada:

KeyError capturado: KeyError('price_cents')

Explicación: package.context es un diccionario normal — si booking_agent no incluyó price_cents en el handoff, package.context["price_cents"] falla con un KeyError ruidoso, exactamente en la línea de la composición final que intenta leerlo. policy_agent en sí no se ve afectado —su propio guion no depende de context para responder la política general—, pero la personalización final sí lo necesita, y falla de forma clara en vez de mostrar un cargo vacío o inventado. Esto confirma, ejecutando, por qué la lección 03 insistió en que context debe incluir todo lo que el receptor —o quien componga la respuesta final— vaya a usar: omitir un campo necesario no falla silenciosamente, falla exactamente donde se necesitaba ese dato.


Resumen y siguiente paso

  • Ejecutamos el handoff completo de Reservo: booking_agent cotiza, se topa con la pregunta de no-presentación, cede el turno con un paquete mínimo; policy_agent responde con la política general, grounded en su propio search_docs.
  • El resultado real: la respuesta final combinó la política general de policy_agent con un cargo específico de 3000 centavos (50% de 6000), calculado a partir de price_cents que viajó en el context del paquete — un dato que policy_agent nunca hubiera podido calcular por sí mismo.
  • policy_agent nunca vio el historial de booking_agent ni la petición original completa — solo el task y el context del paquete, confirmando en código lo que la lección 03 midió en bytes.
  • run_with_handoff no vuelve al emisor después de que el receptor responde — el handoff cede el turno, no lo presta.

Siguiente lección: 06 — Guardando contra cadenas de handoffs sin fin. Extendemos el orquestador con guardas explícitas: qué pasa si el receptor también intenta ceder el turno, o si cede a un especialista que no existe.


Recursos adicionales

  1. Anthropic — Multi-agent research system — Un caso real de Anthropic donde un sub-agente transfiere una tarea con el contexto mínimo necesario, sin duplicar todo el historial de la conversación principal.
  2. Anthropic — Building effective agents — El principio de mantener cada paso de una orquestación con el contexto justo para su tarea, la misma disciplina que esta lección ejecuta de punta a punta.
  3. Anthropic — Messages API reference — La forma exacta de tool_use/tool_result/stop_reason que cada especialista de este handoff respeta, sin cambios.
  4. Python — Operadores de división entera (//) — El operador detrás de price_cents // 2, el mismo usado en toda la guía para calcular descuentos y cargos en centavos, sin floats.