Módulo 7: Orchestrating The Full Reservo System

La corrida completa, ejecutada de punta a punta

Descripción

Las seis lecciones anteriores construyeron cada pieza de la composición por separado: el criterio de decisión (02), el pipeline como una pieza (03), el fan-out generalizado a callables (04), el handoff adentro de una rama del fan-out (05), y el blackboard escrito solo fuera de la sección paralela (06). Esta lección las junta todas, de una sola vez, sobre la petición completa de Ana: los tres patrones corriendo juntos, el blackboard compartido, y —lo único genuinamente nuevo de esta lección— la respuesta final compuesta que Ana recibe, construida combinando lo que produjeron los tres tracks.

No hay ninguna pieza de bajo nivel nueva en esta lección. Es la primera vez que ves el sistema completo funcionando de principio a fin: supervisor, pipeline, fan-out, handoff y blackboard, sobre una sola petición, en una sola corrida.

Conexión con el módulo

Esta lección reusa, sin cambios, absolutamente todo lo construido en las lecciones 02 a 06: Track, run_pipeline, run_tracks_parallel, run_with_handoff, Blackboard. Lo único nuevo es la composición de la respuesta final —que combina el payload del pipeline, la respuesta de pricing_agent, y el context del handoff— y el resumen de cierre que confirma, con la corrida ya terminada, qué patrón resolvió cada parte de la petición original. El Módulo 8 retoma exactamente esta misma corrida como la base del sistema completo que entrega como capstone.


Analogía: el pedido completo, servido

Cierra la escena del restaurante que abrió este módulo. El maître ya decidió el plan (lección 02). La línea de montaje ya cotizó, validó y confirmó la reserva principal (lección 03). Los dos mozos ya resolvieron sus preguntas independientes a la vez (lección 04), y uno de ellos ya llamó al sommelier a mitad de camino (lección 05). La pizarra de la cocina ya tiene anotado lo que importa (lección 06). Lo único que falta —y es lo que hace esta lección— es que alguien junte todo eso en una sola respuesta y se la lleve a la mesa: no tres papeles sueltos, una sola frase coherente que responde exactamente lo que Ana preguntó, en el mismo orden en que lo preguntó.


Ejemplo trabajado: la corrida completa

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


# ---- M3: pipeline (sin cambios) ----
@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


# ---- M5: handoff (sin cambios) ----
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


# ---- M6: blackboard (sin cambios) ----
WRITE_SEQ = itertools.count(1)


@dataclass
class WriteLogEntry:
    seq: int
    writer: str
    field: str
    value: object


@dataclass
class Blackboard:
    member: str | None = None
    room: str | None = None
    tier: str | None = None
    hours: int | None = None
    price_cents: int | None = None
    booking_id: int | None = None
    log: list = field(default_factory=list)

    def write(self, writer, **fields):
        for key, value in fields.items():
            setattr(self, key, value)
            self.log.append(
                WriteLogEntry(seq=next(WRITE_SEQ), writer=writer, field=key, value=value)
            )


# ---- Módulo 7: la capa de composición (lección 02 + 04) ----
@dataclass
class Track:
    key: str
    pattern: str  # "pipeline" | "fanout" | "handoff" -- ningún patrón nuevo, solo la etiqueta
    description: str


def run_tracks_parallel(jobs):
    """jobs: dict {track_key: callable sin argumentos}. Mismo mecanismo de
    ThreadPoolExecutor + as_completed que run_fanout_parallel (M4), pero
    despachando un callable arbitrario en vez de siempre run_specialist."""
    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


print("--- petición compuesta ---")
REQUEST = (
    "Ana (equipo de diseño): cotiza y reserva Focus pro 3h para el lanzamiento, "
    "validando la política de cancelación antes de confirmar. Aparte, compara "
    "Studio y Boardroom pro 3h por si necesitamos más espacio, y de paso cotiza "
    "el Boardroom pro 2h para la reunión de cierre -- dime también qué pasa si "
    "alguien del equipo no llega a esa reunión."
)
print(REQUEST)

bb = Blackboard()

print()
print("=== Paso 1 (concepto, supervisor): decide el plan de tres tracks ===")
PLAN = [
    Track(key="book_focus", pattern="pipeline",
          description="cotizar Focus pro 3h, validar política de cancelación, confirmar"),
    Track(key="compare_rooms", pattern="fanout",
          description="comparar Studio y Boardroom pro 3h -- sub-pregunta independiente"),
    Track(key="boardroom_no_show", pattern="handoff",
          description="cotizar Boardroom pro 2h; si aparece la pregunta de no-presentación, ceder el turno"),
]
for t in PLAN:
    print(f"  [{t.key:<18}] patrón={t.pattern:<9} -- {t.description}")

bb.write("supervisor", member="Ana")
print(f"supervisor escribe member={bb.member!r} (antes de abrir los tracks en paralelo)")

# --- Track 1: pipeline (M3, sin cambios) ---
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,
    )


# --- Track 2: fan-out plano (M4, un solo especialista) ---
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)


# --- Track 3: handoff (M5, sin cambios) dentro de una rama del fan-out ---
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()
print("=== Paso 2 (ejecutado): los tres tracks corren a la vez, con ThreadPoolExecutor ===")
jobs = {
    "book_focus": job_book_focus,
    "compare_rooms": job_compare_rooms,
    "boardroom_no_show": job_boardroom_no_show,
}
raw_results = run_tracks_parallel(jobs)
print("claves según llegaron (informativo, NO se usa para imprimir):", list(raw_results.keys()))

print()
print("=== Paso 3: resultado de cada track, SIEMPRE impreso en orden fijo (sorted por key) ===")
for key in sorted(raw_results):
    print(f"--- track {key!r} ({[t.pattern for t in PLAN if t.key == key][0]}) ---")
    if key == "book_focus":
        payload, trace = raw_results[key]
        for step in trace:
            print(f"  etapa {step['stage']} ({step['label']}) -> {step['agent']}: {step['output']}")
        print(f"  payload final: {payload}")
    elif key == "compare_rooms":
        final, history = raw_results[key]
        print(f"  respuesta: {final['content'][0]['text']}")
    elif key == "boardroom_no_show":
        final, trace = raw_results[key]
        print(f"  handoff: {trace[0]['agent']} -> {trace[0]['package'].receiver}")
        print(f"  respuesta final (de {trace[-1]['agent']}): {final['content'][0]['text']}")
    print()

print("=== Paso 4 (ejecutado, tras cerrar el pool -- sin escrituras concurrentes): "
      "booking_agent escribe la reserva real al Blackboard ===")
payload_focus, _ = raw_results["book_focus"]
bb.write("booking_agent", room=payload_focus["room"], tier=payload_focus["tier"],
         hours=payload_focus["hours"], price_cents=payload_focus["price_cents"])
bb.write("booking_agent", booking_id=payload_focus["booking_id"])
print(f"booking_agent escribió room={bb.room!r} tier={bb.tier!r} hours={bb.hours!r} "
      f"price_cents={bb.price_cents!r} booking_id={bb.booking_id!r}")
print("compare_rooms y boardroom_no_show NO escriben al Blackboard -- son cotizaciones de "
      "comparación, no la reserva de registro (misma regla que pricing_agent en el Módulo 6).")

print()
print("--- estado final del Blackboard ---")
print(bb)
print()
print("--- el log completo de writes (todos ocurrieron FUERA de la sección paralela) ---")
for entry in bb.log:
    print(f"  #{entry.seq} {entry.writer:<14} escribió {entry.field}={entry.value!r}")

print()
print("--- respuesta final compuesta para Ana (concepto, grounded en los tres tracks) ---")
compare_final, _ = raw_results["compare_rooms"]
no_show_final, no_show_trace = raw_results["boardroom_no_show"]
no_show_context = no_show_trace[0]["package"].context
no_show_charge = no_show_context["price_cents"] // 2
final_response = (
    f"Reservé Focus pro {bb.hours}h para {bb.member} "
    f"({bb.price_cents} centavos, confirmación #{bb.booking_id}) -- ya validé "
    f"la política de cancelación. {compare_final['content'][0]['text']} Para la "
    f"reunión de cierre, el Boardroom pro 2h sale {no_show_context['price_cents']} "
    f"centavos; si alguien no llega, el cargo de no-presentación sería de "
    f"{no_show_charge} centavos."
)
print(final_response)

print()
print("--- resumen: qué patrón resolvió cada parte de la petición ---")
for t in PLAN:
    print(f"  {t.key:<18} -> {t.pattern:<9} -- {t.description}")

Qué esperar (corrida real; la línea de "claves según llegaron" puede variar entre ejecuciones — el resto, no):

--- petición compuesta ---
Ana (equipo de diseño): cotiza y reserva Focus pro 3h para el lanzamiento, validando la política de cancelación antes de confirmar. Aparte, compara Studio y Boardroom pro 3h por si necesitamos más espacio, y de paso cotiza el Boardroom pro 2h para la reunión de cierre -- dime también qué pasa si alguien del equipo no llega a esa reunión.

=== Paso 1 (concepto, supervisor): decide el plan de tres tracks ===
  [book_focus        ] patrón=pipeline  -- cotizar Focus pro 3h, validar política de cancelación, confirmar
  [compare_rooms     ] patrón=fanout    -- comparar Studio y Boardroom pro 3h -- sub-pregunta independiente
  [boardroom_no_show ] patrón=handoff   -- cotizar Boardroom pro 2h; si aparece la pregunta de no-presentación, ceder el turno
supervisor escribe member='Ana' (antes de abrir los tracks en paralelo)

=== Paso 2 (ejecutado): los tres tracks corren a la vez, con ThreadPoolExecutor ===
claves según llegaron (informativo, NO se usa para imprimir): ['boardroom_no_show', 'book_focus', 'compare_rooms']

=== Paso 3: resultado de cada track, SIEMPRE impreso en orden fijo (sorted por key) ===
--- track 'boardroom_no_show' (handoff) ---
  handoff: booking_agent -> policy_agent
  respuesta final (de policy_agent): 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.

--- track 'book_focus' (pipeline) ---
  etapa 1 (cotizar) -> booking_agent: Focus pro 3h cuesta 6000 centavos.
  etapa 2 (validar la política de cancelación) -> policy_agent: Puedes cancelar sin cargo hasta 2 horas antes del horario reservado. No hay ningún impedimento para confirmar.
  etapa 3 (confirmar la reserva) -> booking_agent: Reservé Focus pro 3h para Ana (confirmación #1).
  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}

--- track 'compare_rooms' (fanout) ---
  respuesta: Studio pro 3h: 9600 centavos. Boardroom pro 3h: 19200 centavos. Studio es la opción más barata de las dos.

=== Paso 4 (ejecutado, tras cerrar el pool -- sin escrituras concurrentes): booking_agent escribe la reserva real al Blackboard ===
booking_agent escribió room='Focus' tier='pro' hours=3 price_cents=6000 booking_id=1
compare_rooms y boardroom_no_show NO escriben al Blackboard -- son cotizaciones de comparación, no la reserva de registro (misma regla que pricing_agent en el Módulo 6).

--- estado final del Blackboard ---
Blackboard(member='Ana', room='Focus', tier='pro', hours=3, price_cents=6000, booking_id=1, log=[WriteLogEntry(seq=1, writer='supervisor', field='member', value='Ana'), WriteLogEntry(seq=2, writer='booking_agent', field='room', value='Focus'), WriteLogEntry(seq=3, writer='booking_agent', field='tier', value='pro'), WriteLogEntry(seq=4, writer='booking_agent', field='hours', value=3), WriteLogEntry(seq=5, writer='booking_agent', field='price_cents', value=6000), WriteLogEntry(seq=6, writer='booking_agent', field='booking_id', value=1)])

--- el log completo de writes (todos ocurrieron FUERA de la sección paralela) ---
  #1 supervisor     escribió member='Ana'
  #2 booking_agent  escribió room='Focus'
  #3 booking_agent  escribió tier='pro'
  #4 booking_agent  escribió hours=3
  #5 booking_agent  escribió price_cents=6000
  #6 booking_agent  escribió booking_id=1

--- respuesta final compuesta para Ana (concepto, grounded en los tres tracks) ---
Reservé Focus pro 3h para Ana (6000 centavos, confirmación #1) -- ya validé la política de cancelación. Studio pro 3h: 9600 centavos. Boardroom pro 3h: 19200 centavos. Studio es la opción más barata de las dos. Para la reunión de cierre, el Boardroom pro 2h sale 12800 centavos; si alguien no llega, el cargo de no-presentación sería de 6400 centavos.

--- resumen: qué patrón resolvió cada parte de la petición ---
  book_focus         -> pipeline  -- cotizar Focus pro 3h, validar política de cancelación, confirmar
  compare_rooms      -> fanout    -- comparar Studio y Boardroom pro 3h -- sub-pregunta independiente
  boardroom_no_show  -> handoff   -- cotizar Boardroom pro 2h; si aparece la pregunta de no-presentación, ceder el turno

Verifica los tres números de la respuesta final a mano: Focus pro 3h = 2500 * 3 * 80 // 100 = 6000, Boardroom pro 2h = 8000 * 2 * 80 // 100 = 12800, cargo de no-presentación = 12800 // 2 = 6400. Los tres salen de tools reales (get_quote, ejecutado), no de texto libre inventado por ningún guion — la única parte "concepto" de todo este párrafo es la decisión del supervisor de armar el plan de tres tracks (Paso 1) y la redacción final que los combina.


Lo que esta corrida demuestra, junto

Una sola petición de Reservo, resuelta en una sola llamada a un puñado de funciones ya construidas, sin ningún patrón nuevo:

book_focus         -> PIPELINE (M3): 3 etapas, orden fijo, la política se valida
                       antes de confirmar -- run_pipeline, sin cambios.

compare_rooms       -> FAN-OUT (M4): una sub-pregunta independiente, resuelta con
                       una sola llamada, corriendo A LA VEZ que las otras dos --
                       run_tracks_parallel, generalización mínima de M4.

boardroom_no_show   -> HANDOFF (M5), DENTRO de una rama del fan-out: booking_agent
                       cotiza, se topa con una pregunta de política, cede el turno
                       a policy_agent -- run_with_handoff, sin cambios.

(sobre todo lo anterior) BLACKBOARD (M6): el supervisor escribe member ANTES de
                       abrir los tracks; booking_agent escribe los hechos reales
                       de la reserva DESPUÉS de que el pool se cierra -- nunca
                       durante la sección paralela.

Y la respuesta final —una sola frase para Ana— combina información de los tres tracks: el precio y la confirmación de book_focus, la comparación de compare_rooms, el precio y el cargo de no-presentación de boardroom_no_show. Ningún track por sí solo tenía toda esa información — la composición final es lo que la junta, igual que ya viste en el Módulo 3 (la síntesis del pipeline) y el Módulo 5 (la síntesis grounded en el context del handoff), ahora combinando tres fuentes en vez de una o dos.


Errores comunes

  1. Pensar que esta lección introduce algún mecanismo nuevo de coordinación. No lo hace — cada pieza de este script es una función ya construida en un módulo anterior, sin modificar. Lo único nuevo es el orden en que se llaman juntas y la respuesta final que las combina.

  2. Construir la respuesta final ANTES del Paso 4. El cargo de no-presentación y el precio de Focus vienen de bb (después de las escrituras del Paso 4) y de raw_results (después del Paso 2) — intentar componer la respuesta antes de que ambos existan fallaría con un NameError o, peor, usaría datos parciales.

  3. Olvidar verificar los tres números a mano. 6000, 12800, 6400 no son literales sueltos en el guion — cada uno sale de una tool real (get_quote) ejecutada, y se puede recalcular con la fórmula de descuento pro (base * 80 // 100) del Módulo 1.

  4. Ejecutar esta lección en un proceso que ya tenía reservas. Si booking_id no sale 1, el proceso ya corrió otro ejemplo antes — esta lección, como todas, asume un Reservo desechable, recién iniciado.


Ejercicios

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

Ejecuta la corrida completa 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 el log del Blackboard salga idéntico al de arriba, aunque la línea de "claves según llegaron" pueda variar.

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 (salvo la línea informativa de orden de llegada), tu Reservo desechable arrancó limpio y la corrida completa funcionó de punta a punta.

Ejercicio 2: Corre la misma petición dos veces seguidas, en el mismo proceso (Medio)

Ejecuta el script completo de esta lección dos veces, dentro del mismo proceso (por ejemplo, dentro de una función run_scenario() que llamas dos veces). Confirma qué cambia entre la primera y la segunda corrida, y qué se mantiene igual.

Ver solución
def run_scenario():
    bb_local = Blackboard()
    bb_local.write("supervisor", member="Ana")
    jobs_local = {
        "book_focus": job_book_focus,
        "compare_rooms": job_compare_rooms,
        "boardroom_no_show": job_boardroom_no_show,
    }
    results_local = run_tracks_parallel(jobs_local)
    payload_local, _ = results_local["book_focus"]
    bb_local.write("booking_agent", room=payload_local["room"], tier=payload_local["tier"],
                    hours=payload_local["hours"], price_cents=payload_local["price_cents"])
    bb_local.write("booking_agent", booking_id=payload_local["booking_id"])
    return bb_local


bb_run1 = run_scenario()
bb_run2 = run_scenario()
print("booking_id corrida 1:", bb_run1.booking_id)
print("booking_id corrida 2:", bb_run2.booking_id)
print("seq del primer write de la corrida 2:", bb_run2.log[0].seq)

Salida esperada (continuando sobre el proceso ya usado en el ejemplo trabajado, así que el conteo sigue desde donde estaba):

booking_id corrida 1: 2
booking_id corrida 2: 3
seq del primer write de la corrida 2: 7

Explicación: booking_id y WRITE_SEQ son contadores (itertools.count) a nivel de móduloreservo_tools._booking_ids y el WRITE_SEQ de este script—, no a nivel de corrida individual. Cada Blackboard() nuevo empieza con log=[] vacío, pero el seq de su primera entrada CONTINÚA desde donde se quedó el contador global, no reinicia en 1. Esto confirma, ejecutando, que "un Reservo desechable, recién iniciado" significa un proceso nuevo — no simplemente una instancia nueva de Blackboard() dentro del mismo proceso.

Ejercicio 3: ¿Qué pasaría si Ana solo pidiera dos de las tres partes? (Difícil)

Sin ejecutar código todavía, imagina que la petición de Ana solo tuviera las primeras dos partes —book_focus y compare_rooms, sin la pregunta del Boardroom para el cierre—. Explica qué cambiaría en PLAN, en jobs, en la respuesta final compuesta, y qué se mantendría exactamente igual en run_tracks_parallel y en el Blackboard.

Ver solución

PLAN tendría dos Track en vez de tres —sin boardroom_no_show—; jobs tendría dos entradas en vez de tres. La respuesta final compuesta perdería la última oración (la del Boardroom y el cargo de no-presentación), porque no habría ningún no_show_context del cual leer price_cents. Lo que no cambiaría en absoluto: run_tracks_parallel sigue funcionando exactamente igual con dos jobs que con tres —su firma no depende de ningún número fijo de tracks—, y el Blackboard seguiría escribiéndose de la misma forma (member antes, los hechos de la reserva después), porque ninguna de esas dos escrituras depende de que exista el tercer track. Esto confirma, sin necesidad de ejecutar nada, que el mecanismo de composición de este módulo escala hacia abajo (menos tracks) tan bien como escala hacia arriba (más tracks, como en el Ejercicio 2 de la lección 04) — la generalización de la lección 04 no tiene ningún número de tracks hardcodeado en ningún punto.


Resumen y siguiente paso

  • Los tres Track de PLAN corrieron juntos, con run_tracks_parallel, sin que ningún mecanismo necesitara modificarse: run_pipeline (M3) resolvió book_focus, run_specialist (M2) resolvió compare_rooms, run_with_handoff (M5) resolvió boardroom_no_show.
  • Los números reales: Focus pro 3h = 6000 centavos, Boardroom pro 2h = 12800 centavos, cargo de no-presentación = 6400 centavos — los tres verificables a mano con la fórmula de descuento pro del Módulo 1.
  • El Blackboard se escribió dos veces —antes y después de la sección paralela, nunca durante— y su log salió idéntico en cada corrida, confirmando la garantía de la lección 06 sobre la petición completa.
  • La respuesta final combinó las tres fuentes en una sola frase — ninguna de las tres, por separado, tenía toda la información que Ana necesitaba.
  • Ningún patrón nuevo se usó en ningún momento de este módulo. Supervisor, pipeline, fan-out, handoff, blackboard — los cinco de los Módulos 2 a 6, combinados sobre una sola petición real.

Siguiente lección: 08 — Mini-proyecto: tres peticiones compuestas para Reservo. Aplicas exactamente este mismo criterio a tres peticiones nuevas — una que necesita los tres patrones, como la de Ana; una que necesita solo dos; y una que necesita solo uno.


Recursos adicionales

  1. Anthropic — Building effective agents — El principio de componer mecanismos simples ya probados, en vez de diseñar uno nuevo para cada petición — el resultado completo de este módulo.
  2. Anthropic — Multi-agent research system — Un orquestador real donde varios mecanismos de coordinación —secuencial, paralelo, delegación, estado compartido— conviven sobre la misma tarea compleja, exactamente como en esta corrida.
  3. Anthropic — Messages API reference — La forma exacta de tool_use/tool_result/stop_reason que cada agente de esta corrida sigue respetando, sin cambios, en los tres patrones a la vez.
  4. Python — concurrent.futures — El módulo detrás de run_tracks_parallel, el único mecanismo genuinamente nuevo de todo el módulo, y aun así una generalización mínima de algo que ya conocías.