Módulo 5: Handoff y delegación
El paquete de handoff: qué va, qué no va
Descripción
La lección 02 mostró qué pasa sin ningún mecanismo de handoff. Esta lección diseña la pieza que lo
hace posible, antes de tocar el runner: HandoffPackage, el paquete mínimo que un agente en curso
le pasa a otro al ceder el turno. La pregunta central no es "cómo se construye" —es un
dataclass de cuatro líneas— sino qué va adentro y qué se queda afuera. Vas a construirlo,
compararlo en bytes contra el historial completo de booking_agent, y confirmar, ejecutando, que
pasar el historial completo donde se espera un paquete mínimo directamente rompe el código —no
es solo una mala práctica, es un error real que puedes reproducir.
Conexión con el módulo
Esta lección no ejecuta ningún handoff todavía —eso es la lección 05—; construye y mide la forma
del paquete que la lección 04 va a usar dentro del mecanismo real. HandoffPackage se reusa sin
cambios en el resto del módulo, igual que AgentMessage (Módulo 1) y SubTask (Módulo 4) se
reusaron sin cambios una vez definidos.
Analogía: la nota que el mesero le deja al sommelier
Retoma al mesero de la lección 01. Cuando llama al sommelier, no le entrega su libreta completa de la noche —todos los pedidos de todas las mesas, las quejas de la mesa 4, el comentario sobre el clima que hizo con la mesa 7—. Le dice una frase: "la mesa 12 pidió el salmón, quieren un vino que maride bien". Esa frase es el paquete de handoff: contiene exactamente lo que el sommelier necesita (qué plato) y nada de lo que no necesita (el resto de la noche del mesero). Si el mesero le entregara la libreta completa, el sommelier tendría que leer páginas enteras para encontrar el único dato relevante — y probablemente terminaría preguntando "¿pero cuál era el plato?" de todos modos.
Ejemplo trabajado: HandoffPackage, y el historial que se queda atrás
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})")
@dataclass
class HandoffPackage:
"""El paquete MÍNIMO que un agente en curso le pasa a otro al ceder el
turno. Nada de historial completo: quién envía, quién recibe, por qué,
la tarea puntual a resolver, y un `context` chico -- solo los datos que
el receptor de verdad necesita para resolver esa tarea puntual."""
sender: str
receiver: str
reason: str
task: str
context: dict = field(default_factory=dict)
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?"
# El historial de booking_agent HASTA el punto donde decidiría ceder el
# turno -- una sola tool ya ejecutada, sin ningún turno de texto todavía.
model_script_up_to_handoff = [
{"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": "(placeholder, no se usa)"}]},
]
_, full_history = run_agent_parallel(COMPOUND_REQUEST, model_script_up_to_handoff, TOOLS_BOOKING)
full_history = full_history[:-1] # cortamos el turno de texto placeholder: nos quedamos justo antes del handoff
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},
)
print("--- el historial completo de booking_agent (lo que NO se pasa) ---")
print(full_history)
print()
print("--- el HandoffPackage (lo que SÍ se pasa) ---")
print(package)
full_bytes = len(str(full_history))
package_bytes = len(str(package))
print()
print(f"bytes del historial completo: {full_bytes}")
print(f"bytes del HandoffPackage: {package_bytes}")
print(f"reducción: {(1 - package_bytes / full_bytes) * 100:.0f}%")
Qué esperar:
--- el historial completo de booking_agent (lo que NO se pasa) ---
[{'role': 'user', 'content': 'Cotiza Focus pro 3h. Y otra cosa, ¿qué pasa si no llego a la reserva?'}, {'role': 'assistant', 'content': [{'type': 'tool_use', 'id': 'toolu_01', 'name': 'get_quote', 'input': {'room': 'Focus', 'tier': 'pro', 'hours': 3}}]}, {'role': 'user', 'content': [{'type': 'tool_result', 'tool_use_id': 'toolu_01', 'content': "{'price_cents': 6000}"}]}]
--- el HandoffPackage (lo que SÍ se pasa) ---
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})
bytes del historial completo: 373
bytes del HandoffPackage: 304
reducción: 18%
Incluso en la interacción más corta posible —una sola tool ya resuelta antes del handoff— el paquete ya pesa menos que el historial completo. Y esto es apenas el punto de partida: la brecha crece con cada turno adicional que tenga la conversación antes del handoff, sin que el paquete crezca un solo byte, porque el paquete no acumula nada — se escribe a mano, con exactamente lo que el receptor necesita.
La brecha crece: un turno más de booking_agent, el mismo paquete
Confirmemos esa afirmación agregando un paso más al trabajo de booking_agent antes del
handoff — por ejemplo, si primero confirma que "Focus" existe con list_rooms() (el hábito de
grounding de agent-fundamentals M1) antes de cotizar.
model_script_longer = [
{"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": "end_turn", "content": [{"type": "text", "text": "(placeholder, no se usa)"}]},
]
_, longer_history = run_agent_parallel(
"¿Qué salas hay? Cotiza Focus pro 3h. Y otra cosa, ¿qué pasa si no llego a la reserva?",
model_script_longer, TOOLS_BOOKING,
)
longer_history = longer_history[:-1]
longer_bytes = len(str(longer_history))
print(f"bytes del historial con UN paso más ({len(longer_history)} mensajes): {longer_bytes}")
print(f"bytes del HandoffPackage (SIN CAMBIOS): {package_bytes}")
print(f"reducción: {(1 - package_bytes / longer_bytes) * 100:.0f}%")
Qué esperar:
bytes del historial con UN paso más (5 mensajes): 720
bytes del HandoffPackage (SIN CAMBIOS): 304
reducción: 58%
Agregamos un turno entero de trabajo interno a booking_agent —list_rooms(), con su
tool_result de las tres salas— y el HandoffPackage no cambió ni un byte: sigue siendo el
mismo package de arriba, porque ninguno de esos datos nuevos es algo que policy_agent necesite
para responder una pregunta de política. La reducción pasó de 18% a 58% con un solo paso adicional.
En una conversación real, con varios intercambios antes de que aparezca la pregunta que excede la
expertise del emisor, esa brecha sigue creciendo — el paquete se mantiene fijo, porque está
diseñado, no acumulado.
Qué SÍ va en el paquete, y por qué
Repasa los cuatro campos de HandoffPackage uno por uno, contra el ejemplo:
sender="booking_agent" -> quién cede el turno (trazabilidad: quién detectó el límite)
receiver="policy_agent" -> a quién se lo cede (el especialista correcto para la tarea)
reason="..." -> por qué (no obligatorio para que el receptor RESUELVA la tarea,
pero esencial para depurar decisiones de handoff más tarde)
task="¿qué pasa si..." -> la pregunta PUNTUAL a resolver -- reescrita, limpia, no la
petición compuesta original completa
context={"price_cents": 6000, ...} -> SOLO los datos que el receptor necesita para dar
una respuesta grounded y específica de ESTA reserva
Fíjate en context: policy_agent no necesita price_cents para encontrar la política —
search_docs no lo usa como argumento—, pero sí lo va a necesitar para personalizar su
respuesta final con un cargo concreto, calculado para esta reserva puntual. Eso es exactamente lo
que vas a ver ejecutado en la lección 05: sin ese dato, policy_agent solo podría citar la política
general (el 50%); con él, puede citar el cargo exacto en centavos para la reserva de este miembro.
La regla práctica: un dato entra al context si el receptor lo va a usar —para su tool o para
su respuesta final—, no "porque podría servir".
Qué NO va, y qué se rompe si lo forzas
El error más común al diseñar un paquete de handoff es pensar "por las dudas, le paso todo". Vamos a confirmar, ejecutando, qué pasa si intentas pasar el historial completo donde el receptor espera una tarea puntual (un string).
def print_history(history):
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}")
def search_docs(query):
return "algo"
TOOLS_POLICY = {"search_docs": search_docs}
script_policy = [
{"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": "listo"}]},
]
# El error: pasar el HISTORIAL COMPLETO como si fuera la tarea (question).
bad_final, bad_history = run_agent_parallel(full_history, script_policy, TOOLS_POLICY)
try:
print_history(bad_history)
except KeyError as e:
print(f"KeyError capturado: {e!r}")
Qué esperar:
KeyError capturado: KeyError('type')
run_agent_parallel no valida qué tipo de dato es question — lo mete tal cual dentro de
{"role": "user", "content": question}. Si question es el historial completo (una lista de
diccionarios {"role": ..., "content": ...}), termina anidado dentro de content, en vez de ser
un string simple. print_history —y cualquier código que espere que content sea un string o una
lista de bloques con "type"— revienta con un KeyError en cuanto intenta leer block["type"] de
un diccionario que en realidad es un mensaje completo, no un bloque. El error no es cosmético: es la
prueba de que el historial completo y un paquete de handoff no tienen la misma forma, y mezclarlos
rompe el contrato que el resto de la orquestación asume.
Errores comunes
-
Pensar que "más contexto nunca hace daño". El ejemplo de arriba lo desmiente con un error real y reproducible — pasar el historial completo donde se espera una tarea puntual no es "generoso", es incompatible con la forma que
run_agent_parallelespera. -
Confundir
reasoncon parte deltask.reasones para trazabilidad y depuración —por qué el emisor decidió transferir—, no es la pregunta que el receptor tiene que resolver. Mezclar los dos en el mismo campo hace quetaskdeje de ser una instrucción limpia. -
Incluir en
contextdatos que el receptor nunca va a usar. Sipolicy_agentnunca referenciamember(el nombre de quien reserva) ni en su tool ni en su respuesta, incluirlo en elcontextes exactamente el "por las dudas" que esta lección advierte contra — no rompe nada de inmediato, pero es la semilla del mismo problema que crece en sistemas más grandes. -
Calcular el tamaño del paquete UNA sola vez y asumir que siempre será así. El paquete de esta lección pesa
304bytes para ESTE ejemplo concreto — uncontextcon más campos, o untaskmás largo, pesaría más. Lo que se mantiene constante no es el número exacto, es que el paquete no crece con la longitud de la conversación previa del emisor.
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 tus números de bytes coinciden con el "Qué esperar" de arriba, para el historial corto (373/304, 18%) y el historial más largo (720/304, 58%).
Ver solución
No hay una única "solución de código" para este ejercicio — es una verificación: si tus dos pares de números coinciden exactamente con los bloques "Qué esperar" del ejemplo trabajado, tu Reservo desechable arrancó limpio y las dos mediciones se reprodujeron sin desvíos.
Ejercicio 2: Diseña el context para un handoff distinto (Medio)
pricing_agent está comparando Studio y Boardroom (tier pro, 2h cada una) cuando el miembro
pregunta "¿y si cancelo la que elija, me cobran algo?". Diseña el HandoffPackage completo para
este handoff de pricing_agent a policy_agent, decidiendo qué campos van en context — ten en
cuenta que, a diferencia del ejemplo trabajado, acá todavía no se eligió una sala ni se reservó nada.
Ver solución
package_pricing = HandoffPackage(
sender="pricing_agent", receiver="policy_agent",
reason="la pregunta de cancelación no vive en mi expertise de comparar precios",
task="¿qué política aplica si cancelo una reserva?",
context={}, # sin sala/precio elegidos todavía, no hay nada específico que pasar
)
print(package_pricing)
Salida esperada:
HandoffPackage(sender='pricing_agent', receiver='policy_agent', reason='la pregunta de cancelación no vive en mi expertise de comparar precios', task='¿qué política aplica si cancelo una reserva?', context={})
Explicación: a diferencia del ejemplo trabajado —donde ya existía una cotización concreta
(price_cents=6000) para personalizar la respuesta—, acá el miembro todavía no eligió ninguna sala:
no hay ningún dato específico de ESTA reserva que context pueda aportar todavía. Un context
vacío es una respuesta de diseño válida, no un error — el paquete sigue siendo mínimo porque no hay
nada más que agregarle sin inventarlo.
Ejercicio 3: ¿Por qué HandoffPackage no incluye un campo history? (Difícil)
Alguien propone agregarle a HandoffPackage un quinto campo, history: list, "por si el receptor
alguna vez necesita ver de dónde viene la conversación". (a) Explica, con el resultado del Ejercicio
de "qué se rompe" de esta lección, qué riesgo técnico introduce ese campo. (b) Explica, en términos
de diseño (no solo de bytes), por qué el context explícito y acotado es preferible a darle acceso
al historial completo, por si acaso.
Ver solución
(a) El riesgo técnico es exactamente el que el ejemplo de "qué se rompe" ya demostró: en cuanto
existe un campo history disponible, es cuestión de tiempo hasta que alguien lo use como reemplazo
de context—pasando el historial completo "para no tener que pensar qué extraer"—, y eso reproduce
el mismo KeyError: 'type' en cualquier código que espere la forma acotada de un context. Tener el
campo disponible invita al mal uso que la lección entera advierte contra.
(b) Un context explícito y acotado obliga al emisor a decidir, en el momento del handoff,
exactamente qué datos importan — una decisión de diseño consciente. Un history completo delega esa
decisión al receptor ("ya te mando todo, fíjate tú qué necesitas"), lo que en la práctica significa
que nadie la toma: el receptor recibe ruido que no sabe cómo usar, y el emisor deja de pensar en cuál
es el contrato real entre los dos agentes. Esta es la misma disciplina que ya viste con
SPECIALISTS[name]["tools"] en el Módulo 2 —un registro explícito, no un acceso abierto a todo—
aplicada ahora al contenido del mensaje entre agentes, no solo a las tools que cada uno puede usar.
Resumen y siguiente paso
HandoffPackage(sender, receiver, reason, task, context)es el paquete mínimo de un handoff — sin ningún campo de historial completo.- Medido en bytes: el paquete pesó 18% menos que el historial más corto posible de
booking_agent, y 58% menos frente a un historial con un solo paso adicional — el paquete no creció, porque no se acumula: se escribe a mano. - Confirmado ejecutando: pasar el historial completo donde se espera una tarea puntual produce
un
KeyError: 'type'real — el historial completo y un paquete de handoff no tienen la misma forma, y mezclarlos rompe el contrato del resto de la orquestación. - La regla práctica para
context: un dato entra si el receptor lo va a usar —para su tool o para su respuesta final—, nunca "por si acaso".
Siguiente lección: 04 — La tool de handoff y el loop interrumpido. Construimos
handoff_to_specialist como pseudo-tool y el runner que la reconoce, deteniendo su propio loop en
vez de despacharla como una tool de dominio.
Recursos adicionales
- Anthropic — Tool use (function calling) overview — La forma exacta de
tool_use/tool_resultque unHandoffPackagerespeta al construirse a partir de un turno del modelo, sin inventar una estructura nueva. - Python —
dataclasses— El módulo detrás deHandoffPackage, la misma herramienta que ya usaste paraAgentMessage(Módulo 1) ySubTask(Módulo 4). - Anthropic — Building effective agents — El principio de mantener cada paso de una orquestación con el contexto mínimo necesario, en vez de acumular todo el historial "por si acaso" — el eje completo de esta lección.
- Python —
len()y representación de objetos (__repr__) — La base destr(package), usado en esta lección para medir el tamaño real del paquete en bytes.