Módulo 5: Handoff y delegación

Midiendo el costo de un handoff directo

Descripción

Las lecciones anteriores construyeron el mecanismo y lo endurecieron con guardas. Esta lección responde la pregunta económica que sostiene todo el módulo: ¿cuánto ahorra, en llamadas al modelo y en hops de coordinación, que booking_agent ceda el turno directamente a policy_agent, frente a la alternativa de que no exista ningún mecanismo de handoff, y booking_agent tenga que rendirse y devolver el control a un supervisor externo para que este vuelva a rutear? Vas a correr la MISMA tarea de Reservo por los dos caminos, contar las llamadas y los hops de cada uno, y confirmar con números —no con intuición— que ceder el turno directamente es más barato.

Conexión con el módulo

Esta lección reusa run_specialist_with_handoff (04) y el guion completo del handoff de la lección 05, sin cambios. El "Camino B" —la vuelta obligada al supervisor— es la alternativa contrafáctica que la lección 02 dejó planteada en prosa (los dos modos de falla sin handoff); acá se cuantifica con la misma disciplina de conteo que ya usaron el Módulo 1 (lección 05), el Módulo 2 (lección 06), el Módulo 3 (lección 05) y el Módulo 4 (lección 07).


Analogía: el mesero que llama al sommelier, contra el mesero que va a preguntarle al gerente

Retoma al mesero de la lección 01. En el Camino A —el que este módulo construyó—, el mesero llama directamente al sommelier: una sola transferencia, el sommelier responde, listo. En un restaurante sin ese mecanismo, el mesero tendría que ir hasta el gerente, explicarle que no puede resolver la pregunta, esperar a que el gerente decida a quién mandarla, y recién ahí el sommelier se entera de la mesa 12. Son más pasos para llegar exactamente al mismo lugar — el sommelier respondiendo la misma pregunta sobre el mismo plato. La única diferencia es cuánto tardó en llegar ahí, y cuánta gente tuvo que involucrarse en el camino.


Ejemplo trabajado: los dos caminos, contados

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)
    ]


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})")


def count_model_calls(history):
    return sum(1 for m in history if m["role"] == "assistant")


def count_tool_calls(history):
    total = 0
    for m in history:
        if isinstance(m["content"], list):
            total += sum(1 for b in m["content"] if b["type"] == "tool_use")
    return total


HANDOFF_TOOL_NAME = "handoff_to_specialist"


def count_domain_tool_calls(history):
    """Como count_tool_calls, pero sin contar la pseudo-tool de handoff --
    ceder el turno no es una acción de dominio (cotizar, buscar política);
    es una decisión de coordinación."""
    total = 0
    for m in history:
        if isinstance(m["content"], list):
            total += sum(
                1 for b in m["content"]
                if b["type"] == "tool_use" and b["name"] != HANDOFF_TOOL_NAME
            )
    return total


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}},
}


@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)


# ============================================================
# Camino A: handoff directo (la lección 05, contado)
# ============================================================
COMPOUND_REQUEST = "Cotiza Focus pro 3h. Y otra cosa, ¿qué pasa si no llego a la reserva?"

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": "la pregunta de no-presentación no vive en 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 = [
    {"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": "Cobramos el 50% del precio cotizado como cargo por no-presentación."}]},
]

_, hist_booking_a, package_a = run_specialist_with_handoff("booking_agent", COMPOUND_REQUEST, script_booking_handoff)
booking_calls_a = count_model_calls(hist_booking_a)          # get_quote + decisión de handoff
booking_tools_a = count_domain_tool_calls(hist_booking_a)    # solo get_quote -- el handoff no es una tool de dominio

final_policy, hist_policy, package_policy = run_specialist_with_handoff("policy_agent", package_a.task, script_policy)
policy_calls = count_model_calls(hist_policy)
policy_tools = count_tool_calls(hist_policy)

calls_a = booking_calls_a + policy_calls
hops_a = 1  # booking_agent -> policy_agent, directo (una sola transferencia)

print("--- Camino A: handoff directo ---")
print(f"booking_agent hasta el handoff: {booking_calls_a} llamadas al modelo ({booking_tools_a} tool call + 1 decisión de handoff)")
print(f"policy_agent:                    {policy_calls} llamadas al modelo ({policy_tools} tool call)")
print(f"TOTAL llamadas al modelo: {calls_a}")
print(f"TOTAL hops: {hops_a}")

# ============================================================
# Camino B: sin handoff -- booking_agent se rinde, el supervisor
# re-rutea (el contrafáctico de la lección 02)
# ============================================================
BAILOUT_CALLS = 1      # concepto: booking_agent declara que no puede resolver el resto y cede el turno
ROUTE_CALLS = 1         # concepto: el supervisor re-lee el residual y decide a quién mandarlo
COMPOSE_CALLS = 1       # concepto: el supervisor compone la respuesta final combinando ambas partes

calls_b = booking_tools_a + BAILOUT_CALLS + ROUTE_CALLS + policy_calls + COMPOSE_CALLS
hops_b = 3   # booking_agent -> supervisor, supervisor -> policy_agent, policy_agent -> supervisor

print()
print("--- Camino B: sin handoff (vuelta obligada al supervisor) ---")
print(f"booking_agent: {booking_tools_a} llamada (get_quote) + {BAILOUT_CALLS} (se rinde) = {booking_tools_a + BAILOUT_CALLS}")
print(f"supervisor:    {ROUTE_CALLS} (re-rutea) + {COMPOSE_CALLS} (compone) = {ROUTE_CALLS + COMPOSE_CALLS}")
print(f"policy_agent:  {policy_calls} llamadas al modelo ({policy_tools} tool call)")
print(f"TOTAL llamadas al modelo: {calls_b}")
print(f"TOTAL hops: {hops_b}")

print()
print(f"{'':24}{'A: handoff directo':>22}{'B: vuelta al supervisor':>26}")
print(f"{'llamadas al modelo':24}{calls_a:>22}{calls_b:>26}")
print(f"{'hops':24}{hops_a:>22}{hops_b:>26}")
extra_calls = calls_b - calls_a
extra_hops = hops_b - hops_a
pct = extra_calls / calls_a * 100
print()
print(f"diferencia: el camino B (sin handoff) usa {extra_calls} llamadas al modelo MÁS que "
      f"el camino A (handoff directo) ({pct:.0f}% más), y paga {extra_hops} hops de "
      f"coordinación que el handoff evita por completo -- para la MISMA tarea resuelta, "
      f"con la MISMA respuesta final.")

Qué esperar:

--- Camino A: handoff directo ---
booking_agent hasta el handoff: 2 llamadas al modelo (1 tool call + 1 decisión de handoff)
policy_agent:                    2 llamadas al modelo (1 tool call)
TOTAL llamadas al modelo: 4
TOTAL hops: 1

--- Camino B: sin handoff (vuelta obligada al supervisor) ---
booking_agent: 1 llamada (get_quote) + 1 (se rinde) = 2
supervisor:    1 (re-rutea) + 1 (compone) = 2
policy_agent:  2 llamadas al modelo (1 tool call)
TOTAL llamadas al modelo: 6
TOTAL hops: 3

                            A: handoff directo   B: vuelta al supervisor
llamadas al modelo                           4                         6
hops                                         1                         3

diferencia: el camino B (sin handoff) usa 2 llamadas al modelo MÁS que el camino A (handoff directo) (50% más), y paga 2 hops de coordinación que el handoff evita por completo -- para la MISMA tarea resuelta, con la MISMA respuesta final.

policy_agent cuesta exactamente lo mismo en los dos caminos (2 llamadas, 1 tool call) — el trabajo real de responder la pregunta de política no cambia. La diferencia completa está en la coordinación que rodea ese trabajo: el Camino B necesita que booking_agent produzca un turno extra para "rendirse" (BAILOUT_CALLS), que el supervisor lea el residual y decida a quién mandarlo (ROUTE_CALLS), y que el supervisor componga la respuesta final combinando las dos partes (COMPOSE_CALLS) — tres llamadas de coordinación que el handoff directo no necesita, porque booking_agent ya sabe, en el momento, a quién transferirle el control.


Por qué el Camino B tiene 3 hops, y el Camino A tiene 1

Camino A (handoff directo):
  booking_agent -> policy_agent                              (1 hop)

Camino B (vuelta al supervisor):
  booking_agent -> supervisor                                 (hop 1: "no puedo con esto")
  supervisor -> policy_agent                                  (hop 2: re-ruteo)
  policy_agent -> supervisor                                  (hop 3: resultado de vuelta)

Cada hop es un mensaje real que hay que armar, enviar y procesar — el mismo principio que ya estableció el Módulo 1, lección 03, sobre el costo de coordinar. El handoff directo colapsa una cadena de tres mensajes en uno solo, porque el agente que reconoce el límite ya sabe a quién transferirle el control — no necesita que un tercero se lo diga.


Lo que este número NO dice

Esta comparación mide el costo de coordinar, no la calidad de la decisión. El Camino B, con su supervisor externo revisando cada re-ruteo, podría tener una ventaja que este conteo no captura: un supervisor con visión completa del sistema podría, en teoría, detectar patrones que un agente individual no ve —por ejemplo, que policy_agent está recibiendo demasiados handoffs de booking_agent, una señal de que quizás conviene rediseñar las tools de booking_agent—. Esta guía no construye esa capa de supervisión sobre los handoffs —queda fuera de alcance—, pero vale la pena tenerlo presente: el número que mide esta lección es el costo de una transacción puntual, no el valor de la observabilidad que un coordinador central podría aportar a largo plazo.


Errores comunes

  1. Sumar mal el costo del Camino B. `booking_tools_a + BAILOUT_CALLS + ROUTE_CALLS + policy_calls

    • COMPOSE_CALLSno es lo mismo quecalls_a + 2` — hay que reconstruir el camino completo desde cero, no simplemente "agregarle 2" al resultado del Camino A. La lección construye los dos por separado, a propósito, para evitar ese atajo incorrecto.
  2. Olvidar que count_domain_tool_calls excluye la pseudo-tool de handoff. Si usaras count_tool_calls en vez de count_domain_tool_calls para booking_tools_a, contarías el handoff como si fuera una tool de dominio más (get_quote + handoff_to_specialist = 2), y el Camino B saldría con un número inflado que no corresponde a ningún trabajo real de negocio.

  3. Pensar que este resultado (2 llamadas, 50% más) generaliza a cualquier handoff. Es el resultado de ESTA tarea concreta (booking_agent con una tool ya resuelta antes del handoff, policy_agent con una tool). Un handoff donde el emisor hizo más trabajo antes de ceder el turno, o donde el receptor necesita más pasos, cambia los números absolutos — lo que generaliza es el patrón: la vuelta al supervisor siempre suma, como mínimo, BAILOUT_CALLS + ROUTE_CALLS + COMPOSE_CALLS de coordinación extra sobre el mismo trabajo de fondo.

  4. Concluir que "el supervisor nunca sirve" a partir de este número. El Módulo 2 entero justifica cuándo un supervisor SÍ vale la pena —cuando la decisión inicial de a quién delegar es genuinamente incierta, y necesita ver la petición completa antes de que nadie trabaje—. Esta lección compara un caso específico: qué pasa cuando la necesidad de un segundo especialista aparece durante el trabajo, no antes — ahí es donde el handoff directo gana.


Ejercicios

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

Ejecuta la comparación completa de esta lección tú mismo y confirma, línea por línea, que tus números coinciden con el "Qué esperar" de arriba: 4 vs. 6 llamadas, 1 vs. 3 hops.

Ver solución

No hay una única "solución de código" para este ejercicio — es una verificación: si tu tabla final coincide exactamente con la del ejemplo trabajado, tu Reservo desechable arrancó limpio y las dos mediciones se reprodujeron sin desvíos.

Ejercicio 2: Repite la medición con un booking_agent que hizo más trabajo antes (Medio)

Repite el Camino A y el Camino B, pero con un booking_agent que primero llama a list_rooms() antes de get_quote (dos tool calls de dominio antes del handoff, en vez de una). Confirma cómo cambia la diferencia entre los dos caminos.

Ver solución
script_booking_3steps = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_00", "name": "list_rooms", "input": {}}]},
    {"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": "...",
                    "task": "¿qué pasa si un miembro no se presenta a una reserva confirmada?",
                    "context": {"price_cents": 6000}}}]},
]
_, hist_b3, package_b3 = run_specialist_with_handoff("booking_agent", "¿Qué salas hay? Cotiza Focus pro 3h. ¿Qué pasa si no llego?", script_booking_3steps)
booking_calls_3 = count_model_calls(hist_b3)
booking_tools_3 = count_domain_tool_calls(hist_b3)

calls_a_3 = booking_calls_3 + policy_calls
calls_b_3 = booking_tools_3 + BAILOUT_CALLS + ROUTE_CALLS + policy_calls + COMPOSE_CALLS
print(f"Camino A: {calls_a_3} llamadas | Camino B: {calls_b_3} llamadas | diferencia: {calls_b_3 - calls_a_3}")

Salida esperada:

Camino A: 5 llamadas | Camino B: 7 llamadas | diferencia: 2

Explicación: el Camino A pasó de 4 a 5 llamadas (una más por list_rooms), y el Camino B pasó de 6 a 7 (también una más), porque el paso nuevo se suma dentro de booking_agent en los dos caminos por igual. La diferencia entre los dos caminos se mantiene en 2 llamadas — exactamente como predice la fórmula: BAILOUT_CALLS + ROUTE_CALLS + COMPOSE_CALLS = 3 de coordinación extra en el Camino B, menos 1 porque el bailout reemplaza lo que hubiera sido la decisión de handoff en el Camino A (ambos son una llamada de "reconocer el límite", solo que una cede directo y la otra se rinde). El costo fijo de la coordinación no depende de cuánto trabajo interno haya hecho el emisor antes de toparse con el límite.

Ejercicio 3: ¿Cuándo el handoff directo deja de ser más barato? (Difícil)

Sin ejecutar código, razona: ¿existe algún escenario donde el Camino B (vuelta al supervisor) podría costar menos llamadas que el Camino A? Piensa en qué pasaría si el supervisor, al re-rutear, pudiera evitar que policy_agent necesitara su propia llamada de search_docs —por ejemplo, si el supervisor ya tuviera la respuesta cacheada de una petición anterior idéntica.

Ver solución

Sí, existe un escenario así, pero no cambia la conclusión de esta lección: si el supervisor tuviera una capa de caché o memoria que evitara que policy_agent repitiera search_docs para una pregunta ya resuelta antes, el Camino B ahorraría la llamada de search_docs de policy_agent — un ahorro que el handoff directo, tal como está construido en este módulo, no tiene (cada handoff dispara una ejecución completa del receptor, sin memoria de handoffs anteriores). Pero fíjate en qué tipo de mejora es esa: no es una ventaja de "volver al supervisor" en sí misma — es una ventaja de agregar caché, algo que tanto el Camino A como el Camino B podrían tener por separado (un handoff directo también podría revisar una caché antes de invocar a policy_agent). La comparación de esta lección aísla el costo de coordinar de cualquier otra capacidad —caché, memoria, observabilidad— que un sistema real podría sumarle a cualquiera de los dos caminos. Ese tipo de estado que persiste entre peticiones, compartido por todo el sistema, es exactamente el terreno del blackboard —Módulo 6— y de agent-memory-and-state-guide si necesita sobrevivir al cierre del proceso.


Resumen y siguiente paso

  • Medimos, de punta a punta, la MISMA tarea —booking_agent cotiza, se topa con una pregunta de no-presentación, involucra a policy_agent— por dos caminos: handoff directo (Camino A) y vuelta obligada a un supervisor externo (Camino B, el contrafáctico sin handoff de la lección 02).
  • Los números reales: Camino A usó 4 llamadas al modelo, 1 hop; Camino B usó 6 llamadas al modelo (50% más), 3 hops — para el mismo trabajo de fondo (policy_agent respondiendo con search_docs, idéntico en los dos caminos).
  • La diferencia completa vino de la coordinación que rodea el trabajo —rendirse, re-rutear, componer—, no del trabajo en sí, exactamente el mismo patrón que ya vio el Módulo 1, lección 05, comparando un agente único contra un sistema multi-agente.
  • Este resultado no dice que un supervisor externo "nunca sirva" — dice que, cuando la necesidad de un segundo especialista aparece durante el trabajo de un agente que ya está en curso, ceder el turno directamente es más barato que forzar una vuelta a un coordinador externo.

Siguiente lección: 08 — Mini-proyecto: handoffs en Reservo. Aplicas el patrón completo del módulo a tres escenarios nuevos, incluyendo el juicio de reconocer cuándo NO corresponde handoff.


Recursos adicionales

  1. Anthropic — Multi-agent research system — El reporte de Anthropic sobre el costo real de la coordinación entre agentes, la misma clase de medición que esta lección ejecuta a mano.
  2. Anthropic — Building effective agents — El principio de minimizar los pasos de coordinación que no aportan trabajo real, el resultado que esta lección confirma con números.
  3. Anthropic — Messages API reference — La forma exacta de tool_use/tool_result/stop_reason que cada camino de esta comparación respeta, sin cambios.
  4. Python — concurrent.futures — El módulo detrás de dispatch_parallel, corriendo sin cambios dentro de cada especialista de los dos caminos.