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
-
Intentar acceder a
final["content"][0]["text"]sin revisarpackage is Noneprimero. Cuandobooking_agentcede el turno, su propiofinalesNone— el texto que sí importa está en elfinaldel receptor, que es lo querun_with_handoffdevuelve. Confundir los dosfinal(el del emisor, casi siempreNone, con el que devuelve la función) es el error más común al leer este ejemplo por primera vez. -
Pensar que
policy_agent"sabe" que la reserva cuesta 6000 centavos. No sabe nada por sí mismo — solo tiene lo quesearch_docsle devolvió (la política general) y lo que su propiotaskdecía. El número6000(y el3000derivado) llegan enteramente desdepackage.context, afuera depolicy_agent. -
Correr esta lección en un proceso que ya tenía reservas. Aunque este ejemplo no llama a
book_room—sologet_quote, que no tocaBOOKINGS—, 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 deget_quoteno cambia (no depende deBOOKINGS), pero es buena práctica seguir arrancando cada demo en un proceso nuevo, como el resto de la guía. -
Olvidar que
run_with_handoffsolo maneja UN handoff. Sipolicy_agenttambién intentara ceder el turno (por ejemplo, de vuelta abooking_agent), esta versión del orquestador no lo detectaría —receiver_packagese 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_agentcotiza, se topa con la pregunta de no-presentación, cede el turno con un paquete mínimo;policy_agentresponde con la política general, grounded en su propiosearch_docs. - El resultado real: la respuesta final combinó la política general de
policy_agentcon un cargo específico de3000centavos (50% de6000), calculado a partir deprice_centsque viajó en elcontextdel paquete — un dato quepolicy_agentnunca hubiera podido calcular por sí mismo. policy_agentnunca vio el historial debooking_agentni la petición original completa — solo eltasky elcontextdel paquete, confirmando en código lo que la lección 03 midió en bytes.run_with_handoffno 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
- 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.
- 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.
- Anthropic — Messages API reference — La forma exacta de
tool_use/tool_result/stop_reasonque cada especialista de este handoff respeta, sin cambios. - Python — Operadores de división entera (
//) — El operador detrás deprice_cents // 2, el mismo usado en toda la guía para calcular descuentos y cargos en centavos, sin floats.