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

  1. Pensar que la guarda "arregla" el ping-pong en vez de detectarlo. run_with_handoff_guarded no intenta decidir quién tiene razón entre booking_agent y policy_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 de policy_agent para que no intente ceder de vuelta).

  2. 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.

  3. Subir MAX_HANDOFFS sin 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.

  4. Olvidar que run_with_handoff_guarded sigue sin manejar el caso de max_iterations dentro 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 de max_iterations=10 turnos propios), sigue fallando con el RuntimeError de run_agent_with_handoff, sin relación con MAX_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_guarded extiende 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 en SPECIALISTS.
  • Confirmado ejecutando: un ping-pong entre booking_agent y policy_agent produce un RuntimeError claro, con quién intentó ceder a quién y después de cuántos handoffs; un receptor inexistente produce el mismo KeyError ruidoso 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 = 1 es 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

  1. 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.
  2. Python — Excepciones encadenadas (raise ... from) — El mecanismo detrás del Ejercicio 3, que preserva la excepción original mientras agrega contexto de coordinación.
  3. 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.
  4. Python 3.14 — What's New — La versión con la que se ejecutó cada línea de esta lección.