Módulo 5: Handoff y delegación
La tool de handoff y el loop interrumpido
Descripción
Con el paquete diseñado (lección 03), esta lección construye el mecanismo que lo produce:
handoff_to_specialist, una pseudo-tool que el modelo (concepto) puede invocar exactamente
igual que cualquier tool de dominio —mismo protocolo tool_use, mismo input_schema—, pero que el
runner no despacha como una tool real. En vez de eso, run_agent_with_handoff —la variante de
run_agent_parallel que construyes en esta lección— la reconoce, detiene su propio loop ahí
mismo, y devuelve un HandoffPackage armado a partir del input de esa tool. El agente no
termina su tarea con una respuesta de texto; la interrumpe, a propósito, para cederle el turno a
otro.
Conexión con el módulo
Esta lección reusa HandoffPackage de la lección 03 sin cambios, y extiende run_agent_parallel
—no lo reemplaza: la variante que construyes acá se comporta exactamente igual que el original en
todo lo que no sea la pseudo-tool de handoff—. La lección 05 toma run_agent_with_handoff de aquí,
sin modificarlo, y lo usa dos veces seguidas: una para el emisor, una para el receptor, encadenadas
por el paquete que produce esta lección.
Analogía: la campanilla que el mesero toca, no un plato que sirve
Cuando el mesero de la lección 01 decide llamar al sommelier, no hace nada parecido a lo que hace el
resto del tiempo —tomar un pedido, traer un plato—. Toca una campanilla específica, que significa
"necesito que alguien más tome esto desde acá". La cocina no confunde esa campanilla con un pedido
de comida: es una señal de un tipo completamente distinto, que interrumpe el flujo normal del
servicio para redirigirlo. handoff_to_specialist es exactamente esa campanilla: tiene la misma
forma que una tool cualquiera (un nombre, un input_schema), pero el runner la trata de forma
distinta a cualquier tool de dominio en cuanto la reconoce.
Ejemplo trabajado: la pseudo-tool y el runner que se detiene
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."
),
}
def search_docs(query):
q = query.lower()
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",
},
}
HANDOFF_TOOL_NAME = "handoff_to_specialist"
# La forma COMPLETA que tendría esta tool si el modelo la invocara de
# verdad -- igual que FANOUT_TOOL en el Módulo 4, la mostramos aunque la
# decisión en sí sea concepto, para que el contrato quede explícito.
HANDOFF_TOOL = {
"name": HANDOFF_TOOL_NAME,
"description": (
"Transfiere el control de la conversación a otro especialista de Reservo "
"cuando una parte de la petición actual no vive en tu propia expertise. "
"Usa esta tool SOLO durante una tarea que ya estás resolviendo -- no es "
"para el enrutamiento inicial (eso lo hace el supervisor, Módulo 2). Pasa "
"el contexto MÍNIMO que el receptor necesita para responder, nunca tu "
"historial completo."
),
"input_schema": {
"type": "object",
"properties": {
"receiver": {"type": "string",
"enum": ["booking_agent", "policy_agent", "pricing_agent"]},
"reason": {"type": "string"},
"task": {"type": "string"},
"context": {"type": "object"},
},
"required": ["receiver", "reason", "task"],
},
}
@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):
"""Variante de run_agent_parallel (agent-fundamentals M4/M5) que
reconoce UNA tool especial, handoff_to_specialist: si el turno del
modelo la invoca, el loop se DETIENE ahí mismo -- no la despacha con
dispatch_parallel, como haría con cualquier tool de dominio -- y
devuelve un HandoffPackage en vez de seguir iterando. En todo lo
demás, se comporta exactamente igual que run_agent_parallel."""
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):
"""Wrapper de tres líneas, igual que run_specialist del Módulo 2 --
busca el registro de tools del especialista y se lo pasa al runner."""
tools = SPECIALISTS[name]["tools"]
return run_agent_with_handoff(task, model_script, tools, self_name=name)
# --- guion (concepto, claude-sonnet-5) de booking_agent ---
COMPOUND_REQUEST = "Cotiza Focus pro 3h. Y otra cosa, ¿qué pasa si no llego a la reserva?"
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},
}}]},
]
final, history, package = run_specialist_with_handoff("booking_agent", COMPOUND_REQUEST, model_script_booking)
print("--- historial de booking_agent, hasta el handoff ---")
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']}")
print()
print("final (None -- el loop se detuvo en el handoff, no llegó a un texto final):", final)
print("package:", package)
Qué esperar:
--- historial de booking_agent, hasta el handoff ---
[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}}
final (None -- el loop se detuvo en el handoff, no llegó a un texto final): None
package: HandoffPackage(sender='booking_agent', 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})
Fíjate en la forma del historial: booking_agent primero resolvió get_quote con el mecanismo
normal —dispatch_parallel, un tool_result real, todo igual que en cualquier lección anterior—.
Recién en el turno [3], cuando el modelo (concepto) invoca handoff_to_specialist,
run_agent_with_handoff reconoce el nombre y no llama a dispatch_parallel con ese bloque —lo
intercepta antes—. El historial nunca contiene un tool_result para el handoff, porque nunca se
despachó como una tool: el loop simplemente termina ahí, con un package en la mano en vez de un
final con texto.
Por qué handoff_to_specialist no está en ningún SPECIALISTS[...]["tools"]
Nota algo importante en el código: HANDOFF_TOOL_NAME nunca aparece como clave en el diccionario de
tools de ningún especialista. Si estuviera ahí, dispatch_parallel la trataría como cualquier otra
tool —intentaría llamarla como una función de Python con **block["input"], y fallaría, porque no
existe ninguna función handoff_to_specialist() real que reciba receiver, reason, task y
context como argumentos y devuelva algo útil—. El diseño correcto es exactamente el que construye
esta lección: run_agent_with_handoff reconoce el nombre de la pseudo-tool de forma explícita,
antes de intentar despacharla, y la desvía a un camino completamente distinto. La pseudo-tool
vive a un nivel distinto que las tools de dominio: no resuelve nada de Reservo, coordina quién va a
resolverlo.
Comparando con route_to_agent del Módulo 2 y split_into_subtasks del Módulo 4
Las tres pseudo-tools de la guía comparten la misma forma —tool_use con un input_schema
propio—, pero cada una interrumpe el flujo en un momento distinto:
route_to_agent (M2) -> se invoca ANTES de que exista ningún trabajo:
el supervisor decide a quién delegar la petición COMPLETA.
split_into_subtasks (M4) -> se invoca ANTES de que exista ningún trabajo:
reconoce que la petición se separa en sub-tareas
INDEPENDIENTES, y las reparte todas de una vez.
handoff_to_specialist (M5) -> se invoca A MITAD de un trabajo YA en curso:
booking_agent ya resolvió una parte real (get_quote)
antes de reconocer el límite de su propia expertise.
La diferencia no está en la forma de la tool —las tres son tool_use normales, con su propio
input_schema—. Está en cuándo aparece en el historial: route_to_agent y
split_into_subtasks son siempre el primer turno de la conversación (nadie trabajó todavía);
handoff_to_specialist puede aparecer en cualquier turno, después de que el propio agente ya
avanzó una parte genuina del trabajo. Esa es, en código, la misma distinción que la lección 01
planteó en prosa.
Errores comunes
-
Agregar
handoff_to_specialistal diccionariotoolsde un especialista. Rompe el mecanismo de raíz:dispatch_parallelintentaría llamarla como una función real y fallaría, porque no hay ninguna función de Python con ese nombre que haga algo útil — el manejo de la pseudo-tool vive dentro derun_agent_with_handoff, antes de llegar adispatch_parallel, no como una entrada más del registro. -
Olvidar que
finalesNonecuando hay handoff. Un código que asumafinal["content"][0]["text"]sin revisar primero sipackage is Noneva a fallar con unTypeError—finalliteralmente esNoneen ese caso—. La lección 05 muestra el patrón correcto de revisarpackageantes de tocarfinal. -
Pensar que
run_agent_with_handoffreemplaza arun_agent_parallel. No — son dos runners distintos para dos situaciones distintas. Un agente que nunca necesita ceder el turno —comobooking_agentresolviendo una tarea que vive completa en su propio dominio— puede seguir usandorun_agent_parallelsin ningún cambio, exactamente como en los Módulos 2 y 3. -
Asumir que el
inputdehandoff_to_specialistsiempre tienecontext. Elinput_schemasolo exigereceiver,reasonytask—contextno está enrequired. Por esorun_agent_with_handoffusainp.get("context", {})en vez deinp["context"]: un handoff concontextvacío (como el del Ejercicio 2 de la lección 03) es completamente válido.
Ejercicios
Ejercicio 1: Confirma tu propia ejecución (Fácil)
Ejecuta el ejemplo trabajado completo tú mismo y confirma, línea por línea, que tu salida coincide
con el "Qué esperar" de arriba. Presta especial atención a que el historial NO contenga ningún
tool_result para el turno [3].
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 mecanismo de interrupción funcionó sin desvíos.
Ejercicio 2: Un agente que resuelve todo y nunca cede el turno (Medio)
Ejecuta run_specialist_with_handoff con la tarea "Cotiza Studio pro 2h" (sin ninguna segunda
pregunta) y un guion de dos turnos —get_quote, después texto final— sin ningún
handoff_to_specialist. Confirma que package es None y que final sí trae el texto completo.
Ver solución
script_no_handoff = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Studio", "tier": "pro", "hours": 2}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Studio pro 2h cuesta 6400 centavos."}]},
]
final_nh, history_nh, package_nh = run_specialist_with_handoff("booking_agent", "Cotiza Studio pro 2h", script_no_handoff)
print("package:", package_nh)
print("texto final:", final_nh["content"][0]["text"])
Salida esperada:
package: None
texto final: Studio pro 2h cuesta 6400 centavos.
Explicación: cuando el guion nunca invoca handoff_to_specialist, run_agent_with_handoff se
comporta exactamente igual que run_agent_parallel — llega al stop_reason distinto de
"tool_use" y devuelve un final con texto, con package en None. Esto confirma que la variante
de esta lección no cambia el comportamiento de un agente que resuelve toda su tarea sin necesitar
ayuda de nadie más.
Ejercicio 3: ¿Qué pasa si el handoff no es el ÚLTIMO bloque del turno? (Difícil)
run_agent_with_handoff lee block = turn["content"][0] — siempre el primer bloque del turno.
Construye un turno donde handoff_to_specialist aparece como el segundo elemento de
content (con un tool_use de get_quote como primer elemento), y ejecuta ese turno. ¿Qué hace el
runner con el bloque de handoff_to_specialist en ese caso? ¿Por qué el diseño de esta lección
asume que el handoff, si aparece, es el único bloque del turno?
Ver solución
turn_mixed = {"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": HANDOFF_TOOL_NAME,
"input": {"receiver": "policy_agent", "reason": "...", "task": "..."}},
]}
block = turn_mixed["content"][0]
print("bloque leído:", block["name"])
# Y si el runner NO reconoce el handoff en content[0], cae a dispatch_parallel
# con TODOS los bloques del turno -- incluido el de handoff, que no está en
# ningún registro de tools:
try:
dispatch_parallel(turn_mixed["content"], SPECIALISTS["booking_agent"]["tools"])
except KeyError as e:
print(f"KeyError capturado en dispatch_parallel: {e!r}")
Salida esperada:
bloque leído: get_quote
KeyError capturado en dispatch_parallel: KeyError('handoff_to_specialist')
Explicación: run_agent_with_handoff lee content[0], que en este turno mixto es get_quote,
no el handoff — el bloque de handoff_to_specialist en la posición [1] queda completamente
ignorado por el chequeo de handoff: el código solo revisa content[0], así que este turno cae
directo a dispatch_parallel(turn["content"], tools) con todos los bloques, incluido el de
handoff, que no existe en ningún registro de tools de dominio y produce el KeyError de arriba. El
diseño de esta lección
asume, a propósito, que un turno de handoff viene solo —nunca mezclado con tool calls de
dominio en el mismo turno— porque ceder el turno es una decisión de coordinación, no una acción de
negocio: mezclarlas en el mismo turno sería tan confuso como que el mesero, en la misma frase, le
pida al sommelier el vino Y le sirva el plato principal él mismo. Un guion (concepto) bien escrito
nunca produce esa mezcla; este ejercicio confirma qué pasaría si lo hiciera.
Resumen y siguiente paso
handoff_to_specialistes una pseudo-tool: misma formatool_useque cualquier tool real, pero nunca vive en el registro de tools de ningún especialista.run_agent_with_handoffes la variante derun_agent_parallelque la reconoce antes de intentar despacharla: si el turno la invoca, el loop se detiene y devuelve unHandoffPackageen vez de seguir iterando.- Confirmado ejecutando: el historial de
booking_agentnunca contiene untool_resultpara el turno de handoff — el mecanismo interrumpe el flujo, no lo despacha. - Comparado con
route_to_agent(M2) ysplit_into_subtasks(M4) — misma forma detool_use, pero esas dos siempre aparecen ANTES de que exista trabajo;handoff_to_specialistpuede aparecer DESPUÉS de que el agente ya avanzó una parte real.
Siguiente lección: 05 — El handoff de Reservo, ejecutado de punta a punta. Encadenamos dos
llamadas a run_specialist_with_handoff —emisor y receptor— y componemos la respuesta final,
grounded en el contexto que viajó en el paquete.
Recursos adicionales
- Anthropic — Tool use (function calling) overview — La forma exacta de
tool_use/input_schemaquehandoff_to_specialistrespeta, aunque el runner la trate distinto a una tool de dominio. - Anthropic — Building effective agents — El patrón de un agente que reconoce el límite de su propio alcance y transfiere el control, en vez de forzar una respuesta fuera de su expertise.
- Python —
dataclasses— El módulo detrás deHandoffPackage, reusado sin cambios desde la lección 03. - Python — Diccionarios:
dict.get()— El método detrás deinp.get("context", {}), que permite quecontextsea opcional en elinputdel handoff.