Módulo 5: Handoff y delegación

Reconociendo cuándo hace falta un handoff

Descripción

Antes de construir el mecanismo de handoff, vale la pena ver, ejecutando, qué pasa sin él. Esta lección corre la misma petición del Módulo 5 —"Cotiza Focus pro 3h. Y otra cosa, ¿qué pasa si no llego a la reserva?"— contra booking_agent, usando el runner de siempre (run_agent_parallel, sin ningún cambio), y muestra dos formas reales en las que un agente sin mecanismo de handoff falla al toparse con una pregunta fuera de su expertise: responde sin fundamento (y, en este caso concreto, con un dato que no coincide con la política real), o intenta usar una tool que no tiene, y el runner se cae con un error.

Ninguna de las dos es una falla "elegante". La lección 04 construye el mecanismo que las reemplaza por una tercera opción: ceder el turno.

Conexión con el módulo

Esta lección no construye ninguna pieza nueva del handoff todavía — reusa run_agent_parallel y dispatch_parallel de siempre, sobre el registro TOOLS de booking_agent, sin ningún cambio. Es la motivación ejecutada que sostiene el resto del módulo: la lección 04 construye exactamente el mecanismo que evita los dos modos de falla que esta lección demuestra.


Analogía: el mesero, sin sommelier a quien llamar

Retoma el mesero de la lección 01. Ahora imagina el mismo restaurante, pero sin ningún sommelier en el edificio. Cuando le preguntas qué vino marida bien con tu plato, el mesero tiene solo dos caminos: inventar una respuesta con la confianza de quien sí sabe —arriesgándose a recomendarte mal—, o intentar resolverlo él mismo con herramientas que no tiene, como ponerse a buscar en una lista de vinos que ni siquiera está a su cargo, y trabar el servicio de toda la mesa mientras tanto. Ningún mesero decente elige el primer camino a propósito — pero sin un mecanismo real para transferir la pregunta, es exactamente donde termina.


Ejemplo trabajado, modo de falla 1: la respuesta sin fundamento

import concurrent.futures
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)
    ]


def run_agent_parallel(question, model_script, tools, 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
        tool_result_blocks = dispatch_parallel(turn["content"], tools)
        messages.append({"role": "user", "content": tool_result_blocks})
    raise RuntimeError(f"max_iterations alcanzado ({max_iterations})")


TOOLS_BOOKING = {
    "list_rooms": rt.list_rooms, "get_quote": rt.get_quote,
    "book_room": rt.book_room, "cancel_booking": rt.cancel_booking,
}

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

# Guion CONCEPTO (claude-sonnet-5) -- sin ningún mecanismo de handoff,
# booking_agent "resuelve" la segunda pregunta con texto, sin ninguna tool
# que la respalde.
model_script_hallucinate = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": (
            "Focus pro 3h cuesta 6000 centavos. Sobre no presentarte: "
            "normalmente se cobra una penalidad del 30% del total."
        )}]},
]

final, history = run_agent_parallel(COMPOUND_REQUEST, model_script_hallucinate, TOOLS_BOOKING)

print("--- historial completo ---")
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}")

print()
print("texto final:", final["content"][0]["text"])

# Verificación: ¿algún tool_result del historial respalda el "30%"?
backed_by_tool = any(
    isinstance(m["content"], list) and any(
        b["type"] == "tool_result" and "30%" in str(b["content"]) for b in m["content"]
    )
    for m in history
)
print("¿el '30%' viene de algún tool_result?:", backed_by_tool)

Qué esperar:

--- historial completo ---
  [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 texto final: 'Focus pro 3h cuesta 6000 centavos. Sobre no presentarte: normalmente se cobra una penalidad del 30% del total.'

texto final: Focus pro 3h cuesta 6000 centavos. Sobre no presentarte: normalmente se cobra una penalidad del 30% del total.
¿el '30%' viene de algún tool_result?: False

El precio (6000) sí está grounded — viene, línea por línea, del tool_result del turno [2], igual que en cada lección anterior de la guía. Pero el 30% no viene de ningún lado: no hay ninguna tool en TOOLS_BOOKING que sepa nada sobre políticas de no-presentación, así que ese número es, literalmente, inventado por el guion (concepto) que simula la decisión del modelo. Y no es solo "sin fundamento" — es incorrecto: la política real de Reservo (la que vas a ver ejecutada en la lección 05) cobra el 50%, no el 30%. Un miembro que confiara en esta respuesta llegaría tarde a su reserva creyendo que el cargo es mucho menor del que realmente le van a cobrar.


Ejemplo trabajado, modo de falla 2: la tool que no existe

Hay una segunda forma en la que esto puede fallar, más ruidosa que la anterior: en vez de responder con texto inventado, el guion (concepto) intenta despachar una tool que booking_agent nunca tuvo.

model_script_bad_tool = [
    {"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": "search_docs",
         "input": {"query": "no-show"}}]},
]

try:
    run_agent_parallel(COMPOUND_REQUEST, model_script_bad_tool, TOOLS_BOOKING)
except KeyError as e:
    print(f"KeyError capturado: {e!r}")

Qué esperar:

KeyError capturado: KeyError('search_docs')

dispatch_parallel busca tools["search_docs"] dentro de TOOLS_BOOKING —el registro de booking_agent, que solo tiene list_rooms, get_quote, book_room y cancel_booking— y no lo encuentra. El proceso entero se cae con un KeyError, exactamente en medio de una conversación con un miembro real. Es preferible a la respuesta inventada del modo de falla 1 —al menos es ruidoso, no silencioso—, pero sigue siendo una experiencia rota: la conversación termina en una excepción de Python, no en una respuesta útil.


Los dos modos de falla, uno al lado del otro

Modo 1 -- respuesta sin fundamento:
  El agente CONTINÚA la conversación, con confianza, citando un dato que
  nunca vino de ninguna tool. El miembro recibe una respuesta -- pero
  puede ser la respuesta EQUIVOCADA, y no hay forma de saberlo desde
  afuera sin conocer la política real.

Modo 2 -- tool inexistente:
  El agente INTENTA hacer lo correcto -- usar una tool para responder
  con fundamento -- pero esa tool no está en su propio registro. El
  proceso se cae con un KeyError, y la conversación termina sin
  ninguna respuesta.

Ninguno de los dos modos es "el agente decidiendo bien, solo que sin suerte". Los dos son consecuencia directa de la misma causa: booking_agent no tiene ningún mecanismo para reconocer "esto no es mío, pero sé exactamente quién sí puede resolverlo" y actuar en consecuencia. Eso es precisamente lo que construye la lección 04: una tercera opción, donde el agente ni inventa ni se cae — cede el turno.


Por qué esto no es lo mismo que "faltó una tool"

Podrías pensar que la solución más simple es agregarle search_docs a booking_agent — así tendría la tool y no fallaría. Esa idea choca con un principio que ya estableció agent-fundamentals: un agente con un set de tools cada vez más grande deja de tener un rol claro, y el Módulo 1 de esta guía midió el costo de esa alternativa frente a repartir tools entre varios especialistas. Sumarle search_docs a booking_agent no es "darle una herramienta que le faltaba" — es borrar la frontera de expertise que hace que policy_agent exista como especialista aparte. El handoff resuelve el problema real —booking_agent necesita PODER involucrar a policy_agent— sin mezclar sus responsabilidades.


Errores comunes

  1. Pensar que el modo de falla 1 "no es tan grave" porque el agente sí respondió. Es el más peligroso de los dos, precisamente porque no se ve como una falla — la conversación sigue, con un tono seguro, citando un número que resulta estar mal. El Modo 2, aunque más brusco, al menos es imposible de ignorar.

  2. Confundir "sin fundamento" con "inventado al azar". El guion de esta lección no generó el 30% con random — lo escribimos nosotros, a mano, para ilustrar un caso realista. El punto no es que el modelo "tire dados": es que, sin una tool de políticas, cualquier número que produzca —por más razonable que suene— no tiene ningún tool_result real detrás.

  3. Ejecutar el modo de falla 2 esperando que el error se maneje solo. dispatch_parallel no tiene ningún try/except alrededor de tools[b["name"]] — el diseño de esta guía es que un error de coordinación falle ruidoso, no que se trague silenciosamente (el mismo principio que ya viste con SPECIALISTS[nombre_inválido] en el Módulo 2).

  4. Pensar que agregar search_docs a TOOLS_BOOKING "arregla" el problema. Evita el KeyError puntual, pero rompe la separación de expertise que sostiene todo el diseño de Reservo —el mismo error que el Módulo 1, lección 04, ya advirtió contra un agente con demasiadas tools mezcladas.


Ejercicios

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

Ejecuta los dos modos de falla 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 valor False de backed_by_tool en el modo de falla 1.

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 dos bloques "Qué esperar" del ejemplo trabajado, tu Reservo desechable arrancó limpio y los dos modos de falla se reprodujeron sin desvíos.

Ejercicio 2: Compara el número inventado con la política real (Medio)

La política real de no-presentación de Reservo (la que vas a ver ejecutada en la lección 05) cobra el 50% del precio cotizado. Calcula, a mano y después con Python, cuánto sería ese cargo real para Focus pro 3h (6000 centavos), y compáralo con lo que hubiera cobrado el 30% inventado del modo de falla 1.

Ver solución
price_cents = 6000
real_charge = price_cents * 50 // 100
hallucinated_charge = price_cents * 30 // 100
print(f"cargo real (50%): {real_charge} centavos")
print(f"cargo inventado (30%): {hallucinated_charge} centavos")
print(f"diferencia: {real_charge - hallucinated_charge} centavos de menos de lo que el miembro esperaría pagar")

Salida esperada:

cargo real (50%): 3000 centavos
cargo inventado (30%): 1800 centavos
diferencia: 1200 centavos de menos de lo que el miembro esperaría pagar

Explicación: un miembro que confiara en la respuesta del modo de falla 1 esperaría pagar 1800 centavos si no se presenta, pero la política real le cobraría 3000 — una diferencia de 1200 centavos, casi el 40% del cargo real. Esto no es un detalle menor: es exactamente el tipo de dato que un sistema real no puede permitirse inventar, y la razón concreta por la que "responder algo" no es mejor que "ceder el turno a quien sabe".

Ejercicio 3: ¿Por qué dispatch_parallel no intenta "adivinar" la tool correcta? (Difícil)

dispatch_parallel podría, en teoría, buscar si algún OTRO registro de tools (por ejemplo, SPECIALISTS["policy_agent"]["tools"]) tiene una función llamada search_docs, y despacharla desde ahí en vez de fallar con KeyError. Explica, en tres o cuatro líneas, por qué esta guía no construye ese comportamiento, y qué principio de la guía violaría.

Ver solución

Violaría la separación de expertise que sostiene todo el diseño: si dispatch_parallel pudiera "tomar prestada" cualquier tool de cualquier especialista sin que nadie lo decida explícitamente, booking_agent dejaría de tener un set de tools acotado — tendría, de facto, acceso a las tools de los tres especialistas, sin ningún control sobre cuándo. Es exactamente el anti-patrón que el Módulo 1, lección 04, ya señaló: un agente con demasiadas responsabilidades mezcladas deja de tener un rol claro. El handoff resuelve el mismo problema de origen —booking_agent necesita la ayuda de policy_agent— pero de forma explícita y visible: el paquete de handoff (lección 03) deja un rastro claro de quién le pidió ayuda a quién, y por qué, en vez de que el runner adivine silenciosamente qué tool usar de dónde.


Resumen y siguiente paso

  • Ejecutamos, sin ningún mecanismo de handoff, la misma petición compuesta que va a resolver el resto del módulo: booking_agent cotiza correctamente (grounded), pero al toparse con la pregunta de no-presentación, falla de dos formas reales.
  • Modo 1 (respuesta sin fundamento): el agente sigue la conversación con un número —30%— que ningún tool_result respalda, y que además es incorrecto frente a la política real (50%).
  • Modo 2 (tool inexistente): el runner intenta despachar search_docs, que no está en el registro de booking_agent, y el proceso se cae con un KeyError ruidoso.
  • Ninguno de los dos modos es aceptable — la lección 04 construye la tercera opción: ceder el turno, explícitamente, a quien sí tiene la tool correcta.

Siguiente lección: 03 — El paquete de handoff: qué va, qué no va. Antes del mecanismo, diseñamos y medimos, en bytes, el contenido mínimo que un agente le pasa a otro al ceder el turno.


Recursos adicionales

  1. Anthropic — Building effective agents — El principio de mantener a cada agente dentro de un alcance acotado, y las consecuencias reales de que un agente responda más allá de lo que puede fundamentar.
  2. Anthropic — Tool use (function calling) overview — El protocolo tool_use/tool_result que respalda por qué una respuesta sin tool_result detrás es una respuesta sin grounding, el mismo criterio de agent-fundamentals M1.
  3. Python — Excepciones (KeyError) — La excepción que produce el modo de falla 2, cuando dispatch_parallel busca una tool que no está en el registro.
  4. Python 3.14 — What's New — La versión con la que se ejecutó cada línea de esta lección.