Módulo 5: Handoff y delegación
Guardando contra cadenas de handoffs sin fin
Descripción
El orquestador de la lección 05, run_with_handoff, asume que el emisor cede el turno una sola
vez y que el receptor siempre existe. Ninguna de las dos cosas está garantizada: un guion mal
diseñado podría hacer que policy_agent, al recibir el control, intente cederlo de vuelta a
booking_agent —un ping-pong—, o que un handoff apunte a un nombre de especialista que ni siquiera
está en SPECIALISTS. Esta lección construye run_with_handoff_guarded, la versión con dos guardas
explícitas: un límite duro de handoffs por petición, y la confirmación de que el receptor existe
antes de despacharle nada. Vas a disparar los dos casos de verdad y confirmar que ambos fallan
ruidoso, con un mensaje claro, en vez de reenviar el paquete indefinidamente o silenciosamente.
Conexión con el módulo
Esta lección extiende run_with_handoff de la lección 05 —no lo reemplaza: para el caso normal, con
un solo handoff, el comportamiento es idéntico—. Reusa HandoffPackage y
run_specialist_with_handoff sin cambios. La lección 07 mide el costo del camino "feliz"
—exactamente el que esta lección confirma que sigue funcionando sin la guarda de por medio—.
Analogía: la campanilla que no para de sonar
Imagina que el sommelier, al llegar a la mesa 12, en vez de responder la pregunta del vino, toca su propia campanilla y se la devuelve al mesero: "esto en realidad es tuyo". Si el mesero no tiene ningún límite —ninguna regla de "esto ya se transfirió una vez, no lo vuelvo a ceder"—, los dos podrían quedar pasándose la pregunta entre ellos sin que nadie la responda nunca, mientras la mesa espera. Un restaurante bien organizado tiene una regla simple para evitar esto: si algo vuelve a rebotar después de haberse transferido una vez, alguien —el encargado del turno— tiene que resolverlo ahí mismo, no seguir pasándolo.
Ejemplo trabajado: la guarda de cadena de handoffs
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,
}},
"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)
MAX_HANDOFFS = 1
def run_with_handoff_guarded(name, task, model_scripts):
"""Igual que run_with_handoff (lección 05), con DOS guardas: (1) como
mucho MAX_HANDOFFS handoffs por petición -- si el receptor TAMBIÉN
intenta ceder el turno, corta con un error claro en vez de reenviar
el paquete indefinidamente; (2) SPECIALISTS[receiver] tiene que
existir -- ya lo garantiza run_specialist_with_handoff con su propio
KeyError, pero acá lo dejamos explícito en el mensaje de la cadena."""
current_name, current_task = name, task
handoffs = 0
trace = []
while True:
final, history, package = run_specialist_with_handoff(current_name, current_task, model_scripts[current_name])
trace.append({"agent": current_name, "history": history, "package": package})
if package is None:
return final, trace
handoffs += 1
if handoffs > MAX_HANDOFFS:
raise RuntimeError(
f"cadena de handoffs excedida: {package.sender} intentó ceder el turno "
f"a {package.receiver} después de que ya hubo {MAX_HANDOFFS} handoff(s) "
f"en esta petición -- posible ping-pong entre agentes"
)
current_name, current_task = package.receiver, package.task
# --- Caso 1: ping-pong -- policy_agent, al recibir el control, intenta
# devolverlo a booking_agent en vez de resolver la pregunta ---
script_booking_handoff = [
{"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 política",
"task": "¿aplica la política de no-presentación a reservas canceladas?",
"context": {"price_cents": 6000}}}]},
]
script_policy_pingpong = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": HANDOFF_TOOL_NAME,
"input": {"receiver": "booking_agent", "reason": "necesito saber si la reserva sigue activa",
"task": "¿la reserva 1 sigue activa?", "context": {}}}]},
]
try:
run_with_handoff_guarded(
"booking_agent", "Cotiza Focus pro 3h. ¿Aplica no-presentación a reservas ya canceladas?",
{"booking_agent": script_booking_handoff, "policy_agent": script_policy_pingpong},
)
except RuntimeError as e:
print(f"Caso 1 (ping-pong) -- RuntimeError: {e}")
print()
# --- Caso 2: handoff a un especialista que no existe ---
script_booking_bad_receiver = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": HANDOFF_TOOL_NAME,
"input": {"receiver": "shipping_agent", "reason": "typo del modelo",
"task": "envía el contrato por correo", "context": {}}}]},
]
try:
run_with_handoff_guarded(
"booking_agent", "Envíame el contrato de mi reserva por correo.",
{"booking_agent": script_booking_bad_receiver},
)
except KeyError as e:
print(f"Caso 2 (receptor inexistente) -- KeyError: {e!r}")
Qué esperar:
Caso 1 (ping-pong) -- RuntimeError: cadena de handoffs excedida: policy_agent intentó ceder el turno a booking_agent después de que ya hubo 1 handoff(s) en esta petición -- posible ping-pong entre agentes
Caso 2 (receptor inexistente) -- KeyError: KeyError('shipping_agent')
Los dos casos fallan ruidoso, con un mensaje que apunta directo a la causa —no un RuntimeError
genérico "algo salió mal", ni un KeyError sin contexto—. El Caso 1 dice explícitamente quién
intentó ceder el turno a quién, y por qué se cortó (posible ping-pong). El Caso 2 usa el mismo
KeyError que ya viste en el Módulo 2 (SPECIALISTS[target] inexistente) y en el Módulo 4
(SPECIALISTS[agent] de una SubTask inválida) — la misma disciplina de toda la guía, aplicada
ahora al handoff.
El caso normal sigue funcionando igual
Antes de asumir que la guarda "cambió" el comportamiento del handoff, confirmemos que el camino
normal —un solo handoff, como en la lección 05— sigue funcionando exactamente igual con
run_with_handoff_guarded:
script_booking_ok = [
{"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 política, 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}}}]},
]
script_policy_ok = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "search_docs",
"input": {"query": "no-show"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Cobramos el 50% del precio cotizado."}]},
]
final, trace = run_with_handoff_guarded(
"booking_agent", "Cotiza Focus pro 3h. ¿Qué pasa si no llego?",
{"booking_agent": script_booking_ok, "policy_agent": script_policy_ok},
)
print("Caso 0 (normal, un solo handoff) -- OK, sin excepción")
print("agentes en el trace:", [t["agent"] for t in trace])
print("respuesta final:", final["content"][0]["text"])
Qué esperar:
Caso 0 (normal, un solo handoff) -- OK, sin excepción
agentes en el trace: ['booking_agent', 'policy_agent']
respuesta final: Cobramos el 50% del precio cotizado.
La guarda no le agrega ningún costo al camino feliz: con handoffs = 1, nunca supera
MAX_HANDOFFS = 1, así que el while termina en la primera vuelta, exactamente como
run_with_handoff de la lección 05. La guarda solo se activa cuando algo excede el límite —nunca
antes.
Por qué MAX_HANDOFFS = 1, y no 0 o un número más alto
MAX_HANDOFFS = 0 haría que ningún handoff fuera válido — volveríamos al Modo de falla 2 de la
lección 02 (KeyError inmediato en cuanto alguien intentara ceder el turno), que es exactamente lo
que este módulo entero existe para evitar. Un número más alto —por ejemplo, MAX_HANDOFFS = 3—
permitiría cadenas más largas (booking_agent -> policy_agent -> pricing_agent, por ejemplo, si una
pregunta de política terminara necesitando una comparación de precios), a costa de hacer más difícil
distinguir una cadena legítima de un ping-pong real. Esta guía fija MAX_HANDOFFS = 1 a propósito,
como el caso más simple y más común: un agente reconoce un límite, un especialista lo
resuelve. Una cadena de handoffs más larga —varios especialistas en secuencia, cada uno resolviendo
una parte— empieza a parecerse más a un pipeline (M3) que a un handoff puntual, y merecería
diseñarse explícitamente como tal, no como una cadena de cesiones improvisada.
Errores comunes
-
Pensar que la guarda "arregla" el ping-pong en vez de detectarlo.
run_with_handoff_guardedno intenta decidir quién tiene razón entrebooking_agentypolicy_agent—eso requeriría entender el contenido de la disputa, algo fuera del alcance de una guarda mecánica—. Solo detecta que el límite se excedió y falla ruidoso, dejándole la decisión a quien diseñe el sistema (probablemente, corregir el guion depolicy_agentpara que no intente ceder de vuelta). -
Confundir el Caso 1 (ping-pong,
RuntimeError) con el Caso 2 (receptor inexistente,KeyError). Son dos tipos de error distintos, con causas distintas: el primero es un problema de diseño de guiones (dos agentes que se ceden el turno mutuamente); el segundo es un problema de datos (un nombre de especialista que no está en el registro). Los dos fallan ruidoso, pero por razones diferentes. -
Subir
MAX_HANDOFFSsin pensar en el costo. Cada handoff adicional en una cadena agrega, como mínimo, una llamada al modelo más (la decisión de handoff del agente intermedio) — la lección 07 cuantifica exactamente ese costo para el caso de un solo handoff; una cadena más larga lo multiplica. -
Olvidar que
run_with_handoff_guardedsigue sin manejar el caso demax_iterationsdentro de cada agente. Las dos guardas de esta lección son sobre la cadena entre agentes — si un agente individual entra en un loop interno demasiado largo (más demax_iterations=10turnos propios), sigue fallando con elRuntimeErrorderun_agent_with_handoff, sin relación conMAX_HANDOFFS.
Ejercicios
Ejercicio 1: Confirma tu propia ejecución (Fácil)
Ejecuta los tres casos de esta lección (0, 1 y 2) tú mismo y confirma, línea por línea, que tu salida coincide con los bloques "Qué esperar" de arriba.
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 los bloques "Qué esperar" del ejemplo trabajado, tu Reservo desechable arrancó limpio y las tres corridas se reprodujeron sin desvíos.
Ejercicio 2: Sube MAX_HANDOFFS a 2 y repite el Caso 1 (Medio)
Con MAX_HANDOFFS = 2, repite el Caso 1 (el ping-pong entre booking_agent y policy_agent).
¿Sigue fallando? ¿Con qué mensaje, y después de cuántos handoffs?
Ver solución
MAX_HANDOFFS_2 = 2
def run_with_handoff_guarded_v2(name, task, model_scripts, max_handoffs=MAX_HANDOFFS_2):
current_name, current_task = name, task
handoffs = 0
while True:
final, history, package = run_specialist_with_handoff(current_name, current_task, model_scripts[current_name])
if package is None:
return final
handoffs += 1
if handoffs > max_handoffs:
raise RuntimeError(
f"cadena de handoffs excedida: {package.sender} intentó ceder el turno "
f"a {package.receiver} después de que ya hubo {max_handoffs} handoff(s)"
)
current_name, current_task = package.receiver, package.task
try:
run_with_handoff_guarded_v2(
"booking_agent", "Cotiza Focus pro 3h. ¿Aplica no-presentación a reservas ya canceladas?",
{"booking_agent": script_booking_handoff, "policy_agent": script_policy_pingpong},
)
except RuntimeError as e:
print(f"RuntimeError: {e}")
except KeyError as e:
print(f"KeyError: {e!r}")
Salida esperada:
RuntimeError: cadena de handoffs excedida: booking_agent intentó ceder el turno a policy_agent después de que ya hubo 2 handoff(s)
Explicación: con max_handoffs=2, el segundo handoff (policy_agent -> booking_agent) SÍ está
permitido —handoffs llega a 2, que no supera el límite—, así que el while corre
booking_agent una segunda vez, con la tarea "¿la reserva 1 sigue activa?". Como
model_scripts["booking_agent"] sigue apuntando al mismo script_booking_handoff de siempre —un
guion fijo, sin memoria de que ya se ejecutó antes—, esa segunda corrida repite exactamente el mismo
camino: cotiza de nuevo (get_quote, una función pura, sin problema en repetirse) y vuelve a
ceder el turno a policy_agent. Recién ahí, en el tercer handoff, handoffs llega a 3,
supera max_handoffs=2, y el RuntimeError corta la cadena. Subir el límite de 1 a 2 no evitó el
ping-pong — solo le dio una vuelta más antes de cortar. Es la misma conclusión que ya adelantaba la
sección "Por qué MAX_HANDOFFS = 1, y no un número más alto": el límite detecta el problema, no lo
resuelve — la causa real sigue siendo que policy_agent está programado para ceder el turno de
vuelta en vez de resolver la pregunta.
Ejercicio 3: Diseña una guarda para el Caso 2 con un mensaje más específico (Difícil)
run_with_handoff_guarded deja que el KeyError del Caso 2 salga tal cual, desde adentro de
run_specialist_with_handoff. Reescribe el while para capturar ese KeyError y relanzarlo con un
mensaje que incluya el sender y el reason del handoff que falló —información que el KeyError
original no tiene, porque SPECIALISTS[name] no sabe nada sobre paquetes de handoff.
Ver solución
def run_with_handoff_guarded_v3(name, task, model_scripts):
current_name, current_task = name, task
handoffs = 0
while True:
final, history, package = run_specialist_with_handoff(current_name, current_task, model_scripts[current_name])
if package is None:
return final
handoffs += 1
if handoffs > MAX_HANDOFFS:
raise RuntimeError(f"cadena de handoffs excedida en {package.sender} -> {package.receiver}")
try:
current_name, current_task = package.receiver, package.task
# Confirmamos ACÁ, antes de la próxima vuelta, que el receptor existe --
# en vez de dejar que el KeyError salga desde adentro de run_specialist_with_handoff.
if current_name not in SPECIALISTS:
raise KeyError(current_name)
except KeyError as e:
raise KeyError(
f"{package.sender} intentó ceder el turno a {package.receiver!r} "
f"(razón: {package.reason!r}), pero ese especialista no existe en SPECIALISTS"
) from e
try:
run_with_handoff_guarded_v3(
"booking_agent", "Envíame el contrato de mi reserva por correo.",
{"booking_agent": script_booking_bad_receiver},
)
except KeyError as e:
print(f"KeyError: {e}")
Salida esperada:
KeyError: "booking_agent intentó ceder el turno a 'shipping_agent' (razón: 'typo del modelo'), pero ese especialista no existe en SPECIALISTS"
Explicación: el KeyError original (KeyError('shipping_agent'), del Caso 2) es correcto pero
mínimo — solo dice qué clave faltó, no quién la pidió ni por qué. Envolverlo con raise ... from e
preserva la excepción original (visible en el traceback completo, encadenada) mientras agrega el
contexto de coordinación —sender y reason— que ayuda a depurar el problema real más rápido: no
solo "esta clave no existe", sino "este agente, con esta razón, pidió una clave que no existe".
Resumen y siguiente paso
run_with_handoff_guardedextiende el orquestador de la lección 05 con dos guardas: un límite duro de handoffs por petición (MAX_HANDOFFS = 1), y la confirmación implícita de que el receptor existe enSPECIALISTS.- Confirmado ejecutando: un ping-pong entre
booking_agentypolicy_agentproduce unRuntimeErrorclaro, con quién intentó ceder a quién y después de cuántos handoffs; un receptor inexistente produce el mismoKeyErrorruidoso que ya viste en los Módulos 2 y 4. - El caso normal —un solo handoff, como el de la lección 05— sigue funcionando exactamente igual: la guarda no le agrega ningún costo al camino feliz.
MAX_HANDOFFS = 1es una elección de diseño explícita: cadenas más largas empiezan a parecerse a un pipeline (M3), y merecen diseñarse como tal, no como cesiones improvisadas.
Siguiente lección: 07 — Midiendo el costo de un handoff directo. Contamos, con números reales, cuánto cuesta este mismo handoff frente a la alternativa de volver a un supervisor externo cada vez que un agente se topa con un límite.
Recursos adicionales
- Anthropic — Building effective agents — El principio de mantener límites explícitos en cualquier mecanismo de coordinación automática, para que una decisión mal calibrada falle de forma controlada en vez de propagarse sin fin.
- Python — Excepciones encadenadas (
raise ... from) — El mecanismo detrás del Ejercicio 3, que preserva la excepción original mientras agrega contexto de coordinación. - Python — Excepciones (
KeyError,RuntimeError) — Las dos excepciones que esta lección dispara de verdad, la misma disciplina de "fallar ruidoso" de toda la guía. - Python 3.14 — What's New — La versión con la que se ejecutó cada línea de esta lección.