Módulo 7: Orchestrating The Full Reservo System
Un agente hace handoff a mitad de una rama del fan-out
Descripción
Las lecciones 03 y 04 resolvieron dos de los tres Track de PLAN. Esta lección agrega el
tercero: boardroom_no_show, etiquetado handoff en la lección 02. Por dentro, esta sub-tarea
empieza siendo una tarea de booking_agent —cotizar el Boardroom pro 2h— y, a mitad de camino, se
topa con una pregunta que vive en policy_agent —qué pasa si alguien no llega—. booking_agent
cede el turno directamente, con run_with_handoff (Módulo 5), sin volver a consultar a ningún
supervisor.
Lo que esta lección demuestra, ejecutando, es que run_tracks_parallel no necesita saber nada
sobre esto. Desde la perspectiva del fan-out que la contiene, job_boardroom_no_show es un
callable más, exactamente igual que job_book_focus o job_compare_rooms — que por dentro haga
dos llamadas encadenadas en vez de una, o que una de esas llamadas ceda el turno a otro agente, es
invisible para el mecanismo que lo despacha.
Conexión con el módulo
Esta lección reusa, sin cambios, HandoffPackage, run_agent_with_handoff,
run_specialist_with_handoff y run_with_handoff del Módulo 5, y run_tracks_parallel de la
lección 04. No hay ninguna pieza nueva de bajo nivel — la única novedad es que, por primera vez en
la guía, los tres mecanismos (pipeline, fan-out, handoff) corren en la misma corrida, al mismo
tiempo. La lección 06 le agrega el Blackboard compartido a esta misma corrida de tres tracks.
Analogía: un mensajero que, a mitad de camino, le pasa el mandado a otro
Retoma los dos mensajeros de la lección 04 — pero esta vez son tres, y uno de ellos tiene un mandado con una vuelta inesperada. Sale a cotizar un servicio (su especialidad) y, a mitad de camino, el cliente le hace una pregunta sobre un tema que no es el suyo — no vuelve corriendo a preguntarle a quien organizó los mandados del día; simplemente le pasa esa parte puntual a un colega que sí sabe del tema, con la información justa para que pueda responder, y termina ahí su propia parte. Los otros dos mensajeros, mientras tanto, siguen sus propios mandados sin enterarse de nada de esto — cada uno solo le importa entregar su propio resultado cuando termine.
Ejemplo trabajado: tres tracks a la vez — pipeline, fan-out plano, y un fan-out con handoff adentro
import ast
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})")
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."
),
"cancellation-policy": (
"Las reservas se pueden cancelar sin cargo hasta 2 horas antes del "
"horario reservado. Cancelaciones dentro de esas 2 horas aplican "
"el cargo de no-presentación."
),
}
def search_docs(query):
q = query.lower()
if "cancela" in q:
return f"[cancellation-policy] {POLICY_DOCS['cancellation-policy']}"
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}},
}
def run_specialist(name, task, model_script):
tools = SPECIALISTS[name]["tools"]
return run_agent_parallel(task, model_script, tools)
@dataclass
class PipelineStage:
kind: str
name: str
label: str
def last_tool_result(history):
for m in reversed(history):
if isinstance(m["content"], list):
for b in m["content"]:
if b["type"] == "tool_result":
try:
return ast.literal_eval(b["content"])
except (ValueError, SyntaxError):
return b["content"]
return None
def build_stage_task(stage, payload):
if stage.kind == "quote":
return f"Cotiza {payload['room']} {payload['tier']} {payload['hours']}h."
if stage.kind == "validate_policy":
return (f"¿Cuál es la política de cancelación para una reserva de "
f"{payload['room']} de {payload['hours']}h, antes de confirmarla?")
if stage.kind == "confirm":
if payload.get("cleared_to_book"):
return (f"Reserva {payload['room']} {payload['tier']} {payload['hours']}h "
f"para {payload['member']} -- la política de cancelación ya se validó.")
return (f"Reserva {payload['room']} {payload['tier']} {payload['hours']}h "
f"para {payload['member']}.")
raise ValueError(f"no sé armar la tarea de la etapa {stage.kind!r}")
def extract_payload(stage, history, payload):
new_payload = dict(payload)
result = last_tool_result(history)
if stage.kind == "quote":
new_payload["price_cents"] = result["price_cents"]
elif stage.kind == "validate_policy":
new_payload["cancellation_policy"] = result
new_payload["cleared_to_book"] = True
elif stage.kind == "confirm":
new_payload["booking_id"] = result["booking_id"]
new_payload["confirmed"] = result["confirmed"]
return new_payload
def run_pipeline(stages, model_scripts, initial_payload):
payload = dict(initial_payload)
trace = []
for i, stage in enumerate(stages):
task = build_stage_task(stage, payload)
final, history = run_specialist(stage.name, task, model_scripts[i])
payload = extract_payload(stage, history, payload)
trace.append({
"stage": i + 1, "kind": stage.kind, "agent": stage.name,
"label": stage.label, "task": task, "history": history,
"output": final["content"][0]["text"],
})
return payload, trace
def run_tracks_parallel(jobs):
results = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=len(jobs)) as pool:
future_to_key = {pool.submit(fn): key for key, fn in jobs.items()}
for future in concurrent.futures.as_completed(future_to_key):
key = future_to_key[future]
results[key] = future.result()
return results
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)
def run_with_handoff(name, task, model_scripts):
final, history, package = run_specialist_with_handoff(name, task, model_scripts[name])
trace = [{"agent": name, "history": history, "package": package}]
if package is None:
return final, trace
receiver_final, receiver_history, receiver_package = run_specialist_with_handoff(
package.receiver, package.task, model_scripts[package.receiver],
)
trace.append({"agent": package.receiver, "history": receiver_history, "package": receiver_package})
return receiver_final, trace
PIPELINE_STAGES = [
PipelineStage(kind="quote", name="booking_agent", label="cotizar"),
PipelineStage(kind="validate_policy", name="policy_agent",
label="validar la política de cancelación"),
PipelineStage(kind="confirm", name="booking_agent", label="confirmar la reserva"),
]
INITIAL_PAYLOAD = {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}
model_script_quote = [
{"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."}]},
]
model_script_policy = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "search_docs",
"input": {"query": "política de cancelación"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
"Puedes cancelar sin cargo hasta 2 horas antes del horario "
"reservado. No hay ningún impedimento para confirmar."
)}]},
]
model_script_confirm = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "book_room",
"input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Reservé Focus pro 3h para Ana (confirmación #1)."}]},
]
def job_book_focus():
return run_pipeline(
PIPELINE_STAGES,
[model_script_quote, model_script_policy, model_script_confirm],
INITIAL_PAYLOAD,
)
model_script_pricing = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Studio", "tier": "pro", "hours": 3}},
{"type": "tool_use", "id": "toolu_02", "name": "get_quote",
"input": {"room": "Boardroom", "tier": "pro", "hours": 3}},
]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
"Studio pro 3h: 9600 centavos. Boardroom pro 3h: 19200 centavos. "
"Studio es la opción más barata de las dos."
)}]},
]
def job_compare_rooms():
return run_specialist("pricing_agent", "Compara Studio y Boardroom pro 3h.", model_script_pricing)
model_script_boardroom_booking = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Boardroom", "tier": "pro", "hours": 2}}]},
{"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 es política, fuera de mi expertise",
"task": "¿qué pasa si un miembro no se presenta a una reserva confirmada?",
"context": {"room": "Boardroom", "tier": "pro", "hours": 2, "price_cents": 12800},
}}]},
]
model_script_boardroom_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": (
"Si no te presentas a una reserva confirmada y no cancelas con al "
"menos 2 horas de anticipación, Reservo cobra el 50% del precio "
"cotizado como cargo por no-presentación."
)}]},
]
model_scripts_boardroom = {
"booking_agent": model_script_boardroom_booking,
"policy_agent": model_script_boardroom_policy,
}
def job_boardroom_no_show():
return run_with_handoff(
"booking_agent", "Cotiza Boardroom pro 2h. ¿Qué pasa si no llego?", model_scripts_boardroom,
)
print("--- tres tracks a la vez: pipeline, fan-out plano, y un fan-out que ADENTRO hace handoff ---")
jobs = {
"book_focus": job_book_focus,
"compare_rooms": job_compare_rooms,
"boardroom_no_show": job_boardroom_no_show,
}
results = run_tracks_parallel(jobs)
print("claves según llegaron (informativo):", list(results.keys()))
print()
print("=== salida SIEMPRE en el mismo orden (sorted por key del track) ===")
for key in sorted(results):
print(f"--- {key} ---")
if key == "book_focus":
payload, trace = results[key]
print(" payload final:", payload)
elif key == "compare_rooms":
final, history = results[key]
print(" respuesta:", final["content"][0]["text"])
elif key == "boardroom_no_show":
final, trace = results[key]
print(f" {trace[0]['agent']} cedió el turno a {trace[0]['package'].receiver} "
f"(razón: {trace[0]['package'].reason!r})")
print(" respuesta final:", final["content"][0]["text"])
print()
print("=== lo que run_tracks_parallel VE de cada job (no le importa qué corre adentro) ===")
for key in sorted(jobs):
kind = {"book_focus": "un run_pipeline de 3 etapas", "compare_rooms": "una sola llamada a run_specialist",
"boardroom_no_show": "un run_with_handoff de 2 llamadas encadenadas"}[key]
print(f" {key:<18} -> por dentro: {kind}")
Qué esperar (corrida real; el orden de llegada de las claves puede variar entre ejecuciones):
--- tres tracks a la vez: pipeline, fan-out plano, y un fan-out que ADENTRO hace handoff ---
claves según llegaron (informativo): ['compare_rooms', 'boardroom_no_show', 'book_focus']
=== salida SIEMPRE en el mismo orden (sorted por key del track) ===
--- boardroom_no_show ---
booking_agent cedió el turno a policy_agent (razón: 'la pregunta de no-presentación es política, fuera de mi expertise')
respuesta final: Si no te presentas a una reserva confirmada y no cancelas con al menos 2 horas de anticipación, Reservo cobra el 50% del precio cotizado como cargo por no-presentación.
--- book_focus ---
payload final: {'room': 'Focus', 'tier': 'pro', 'hours': 3, 'member': 'Ana', 'price_cents': 6000, 'cancellation_policy': '[cancellation-policy] Las reservas se pueden cancelar sin cargo hasta 2 horas antes del horario reservado. Cancelaciones dentro de esas 2 horas aplican el cargo de no-presentación.', 'cleared_to_book': True, 'booking_id': 1, 'confirmed': True}
--- compare_rooms ---
respuesta: Studio pro 3h: 9600 centavos. Boardroom pro 3h: 19200 centavos. Studio es la opción más barata de las dos.
=== lo que run_tracks_parallel VE de cada job (no le importa qué corre adentro) ===
boardroom_no_show -> por dentro: un run_with_handoff de 2 llamadas encadenadas
book_focus -> por dentro: un run_pipeline de 3 etapas
compare_rooms -> por dentro: una sola llamada a run_specialist
Boardroom pro 2h cuesta 12800 centavos (8000 * 2 * 80 // 100), y el cargo de no-presentación
—calculado más adelante, en la lección 07, cuando se compone la respuesta final— sería la mitad de
eso. Por ahora, fíjate en algo estructural: job_boardroom_no_show corrió dos llamadas a
run_specialist_with_handoff por dentro —una para booking_agent, una para policy_agent— y aun
así ocupó un solo hilo del ThreadPoolExecutor. El handoff, desde afuera, se ve exactamente
igual que cualquier otro job: entra, corre, devuelve un resultado. Nadie en run_tracks_parallel
sabe —ni necesita saber— que dentro de ese hilo hubo una transferencia de control entre dos agentes.
Por qué el handoff no "rompe" la garantía de independencia del fan-out
Podría parecer, a primera vista, que un handoff introduce una dependencia nueva —¿no depende
policy_agent de que booking_agent haya cotizado primero, dentro de ese mismo track?— Sí, mucho:
policy_agent literalmente no correría si booking_agent no hubiera cedido el turno. Pero esa
dependencia vive completamente adentro de job_boardroom_no_show, en el orden secuencial que ya
garantiza run_with_handoff (Módulo 5) — el emisor corre, y si cede el turno, el receptor corre
después, dentro de la misma llamada a esa función. Lo que el fan-out de esta lección exige no es "sin
ninguna dependencia interna" — es "sin dependencia entre tracks": ningún dato que
job_book_focus o job_compare_rooms producen entra en job_boardroom_no_show, ni al revés. Esa
es la frontera que de verdad importa para que los tres puedan correr a la vez sin pisarse.
Errores comunes
-
Pensar que
run_tracks_parallelnecesita un caso especial para manejar jobs con handoff. No lo necesita — y si lo tuviera, sería una señal de que el mecanismo de composición está mal diseñado. La generalización de la lección 04 (un callable por sub-tarea) ya cubre este caso sin ningún cambio. -
Confundir la dependencia INTERNA de un handoff (receptor depende del emisor, dentro del mismo job) con una dependencia ENTRE tracks (que sí rompería el fan-out). Son cosas distintas: la primera es aceptable y ya la maneja
run_with_handoffpor sí solo; la segunda es exactamente lo que la pregunta 1 del criterio de la lección 02 busca detectar antes de etiquetar algo comofanout. -
Olvidar que
boardroom_no_showusabooking_agent, el mismo agente quebook_focus. Que dos tracks usen el mismo nombre de especialista no crea ningún conflicto — cada llamada arun_specialist/run_specialist_with_handoffes independiente, con su propiomessages, su propio guion, y ninguna corre "dentro" del estado de la otra. -
Pensar que el cargo de no-presentación de
12800 // 2 = 6400centavos ya se calculó en esta lección. Todavía no — este ejemplo trabajado se detiene en el resultado de cada track por separado; la composición que combina elcontextdel handoff con el resto de la corrida es tarea de la lección 07.
Ejercicios
Ejercicio 1: Confirma tu propia ejecución (Fácil)
Ejecuta el ejemplo trabajado de esta lección tú mismo y confirma, línea por línea, que tu salida
coincide con el "Qué esperar". Presta especial atención a que boardroom_no_show nunca haya llamado
a book_room — solo get_quote y search_docs.
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 el "Qué esperar" del ejemplo trabajado, y reservo_tools.BOOKINGS tiene
exactamente una reserva (booking_id: 1, de book_focus) al terminar, los tres tracks corrieron sin
desvíos.
Ejercicio 2: Confirma que boardroom_no_show NO reservó nada (Medio)
Después de correr el ejemplo trabajado, inspecciona reservo_tools.BOOKINGS directamente y confirma
que solo contiene la reserva de book_focus —el track de boardroom_no_show solo cotizó, nunca
reservó.
Ver solución
print("reservas en BOOKINGS:", rt.BOOKINGS)
print("cantidad total de reservas:", len(rt.BOOKINGS))
Salida esperada:
reservas en BOOKINGS: {1: {'booking_id': 1, 'room': 'Focus', 'tier': 'pro', 'hours': 3, 'member': 'Ana', 'price_cents': 6000}}
cantidad total de reservas: 1
Explicación: job_boardroom_no_show solo invoca get_quote (dentro de booking_agent, antes
del handoff) y search_docs (dentro de policy_agent, después) — ningún guion de ese track llama a
book_room, así que reservo_tools.BOOKINGS nunca se entera de que existió una cotización para
Boardroom. Esto confirma, ejecutando, que "cotizar" (parte del vocabulario de la petición de Ana:
"cotiza el Boardroom pro 2h") y "reservar" (parte de la petición para Focus: "cotiza y reserva")
son operaciones genuinamente distintas — la misma distinción que ya viste en agent-fundamentals,
ahora dentro de una corrida compuesta.
Ejercicio 3: ¿Qué pasaría si policy_agent también intentara ceder el turno? (Difícil)
Sin ejecutar código todavía, usando lo que ya sabes del Módulo 5, lección 06 ("Guardando contra
cadenas de handoffs sin fin"), explica qué pasaría si el guion de policy_agent dentro de
job_boardroom_no_show invocara, a su vez, handoff_to_specialist de vuelta a booking_agent —y
por qué run_with_handoff, tal como está construida en esta lección, no lo detectaría.
Ver solución
run_with_handoff, tal como se reusa en esta lección (idéntica a la del Módulo 5, lección 05, no la
versión con guardas de la lección 06), solo maneja un handoff: corre al emisor, y si cede el
turno, corre al receptor UNA vez y devuelve su resultado —nunca revisa si el package que devuelve
el receptor es, a su vez, otro handoff—. Si policy_agent intentara ceder el turno de vuelta a
booking_agent, run_specialist_with_handoff(package.receiver, ...) devolvería un tercer valor
(receiver_package) que run_with_handoff calcula pero nunca revisa —la misma limitación que ya
señaló, con este mismo código, el Error común 4 del Módulo 5, lección 05—. El resultado sería
silenciosamente incompleto: la función devolvería el final de policy_agent, que en este caso
sería None (porque cedió el turno en vez de terminar con texto), y cualquier código que asumiera
final["content"][0]["text"] sin revisar fallaría con un TypeError. La guarda que sí detecta y
corta estas cadenas —run_with_handoff_guarded, del Módulo 5, lección 06— no se reusa en este
módulo porque los guiones (concepto) de esta guía nunca construyen una cadena de handoffs sin fin a
propósito; pero el riesgo, si un guion real lo hiciera, sigue siendo el que esa lección ya
documentó.
Resumen y siguiente paso
- El
Trackboardroom_no_showse resolvió conrun_with_handoffdel Módulo 5, sin ningún cambio de código —booking_agentcotiza, se topa con la pregunta de no-presentación, cede el turno apolicy_agentcon el contexto mínimo. run_tracks_parallelcorrió los tres tracks —pipeline, fan-out plano, fan-out-con-handoff-adentro— a la vez, sin ningún cambio respecto a la lección 04: no le importa qué mecanismo corre dentro de cada job.- La dependencia interna de un handoff (receptor después de emisor) no rompe la independencia entre tracks que exige el fan-out — son dos niveles de dependencia distintos.
reservo_tools.BOOKINGSconfirma, ejecutando, que solobook_focusreservó de verdad —boardroom_no_showcotizó, pero nunca confirmó nada.
Siguiente lección: 06 — Un blackboard, compartido por los tres patrones a la vez. Agregamos un
Blackboard a esta misma corrida de tres tracks, y confirmamos, ejecutando, que sus escrituras
quedan siempre en el mismo orden, sin importar que los tracks hayan corrido en paralelo.
Recursos adicionales
- Anthropic — Building effective agents — El principio de un agente que reconoce el límite de su propia expertise y transfiere el control, ahora ejecutado dentro de una rama de un fan-out más grande.
- Python —
concurrent.futures.Future— El objeto que encapsula el resultado de cada job, sin importar cuántas llamadas encadenadas haya hecho por dentro. - Anthropic — Multi-agent research system — Un sistema real donde sub-agentes paralelos pueden, cada uno por su cuenta, delegar partes de su propio trabajo sin que el orquestador de nivel superior lo note.
- Python —
dataclasses— El módulo detrás deHandoffPackage, reusado sin cambios desde el Módulo 5.