Módulo 7: Orchestrating The Full Reservo System

Mini-proyecto: tres peticiones compuestas para Reservo

Descripción

Seis lecciones te dejaron con el criterio completo de composición: las tres preguntas que deciden qué patrón le corresponde a cada parte de una petición (02), el pipeline como una pieza (03), el fan-out generalizado a cualquier callable (04), un handoff dentro de una rama del fan-out (05), el blackboard escrito solo fuera de la sección paralela (06), y la corrida completa de la petición de Ana, de punta a punta (07). Este mini-proyecto no agrega ningún concepto nuevo — te da tres peticiones de Reservo que nunca viste, con formas genuinamente distintas entre sí, y te pide aplicar el criterio completo del módulo: cuántos tracks necesita cada una, con qué patrón se resuelve cada uno, y qué queda escrito en el Blackboard al final.

El entregable de esta lección es el sistema de composición completo —Track, run_pipeline, run_tracks_parallel, run_with_handoff, Blackboard— corriendo contra los tres escenarios del encargo, más el juicio para reconocer que no todas las peticiones compuestas necesitan los tres patrones a la vez — una lección que ya adelantó el Ejercicio 2 de la lección 02, ahora confirmada sobre dos casos nuevos, ejecutados.

Conexión con el módulo

Este mini-proyecto es la síntesis de las seis lecciones anteriores, no una lección nueva. De la 02 usas el criterio de las tres preguntas. De la 03, run_pipeline sin ningún cambio. De la 04, run_tracks_parallel. De la 05, run_with_handoff dentro de una rama del fan-out. De la 06, la disciplina de escribir al Blackboard solo fuera de la sección paralela. De la 07, la forma completa de una corrida de punta a punta con su respuesta final compuesta. Cuando termines, el Módulo 8 toma este mismo sistema y lo entrega como el capstone completo de la guía, con su propia demo de al menos dos peticiones de forma distinta y el conteo de costo de coordinación de cada una.


El encargo

Reservo te pasa tres peticiones que llegaron la misma semana, de tres socios distintos:

Escenario 1 (Sofía): "Cotiza y reserva Boardroom pro 4h para la reunión trimestral, validando
la política de cancelación antes de confirmar. Aparte, compara Focus y Studio pro 4h por si
el equipo crece, y cotiza el Focus pro 2h para la sesión de feedback -- avísame qué pasa si
alguien no llega a esa sesión."

Escenario 2 (Diego): "Reserva el Focus pro 2h para la entrevista de mañana, validando la
política de cancelación antes de confirmar. Aparte, ¿qué pasa si un candidato no llega a una
entrevista reservada?"

Escenario 3 (Camila): "Cotiza el Studio pro 3h para el webinar. Y aparte, ¿qué pasa si no
llego a esa reserva?"

Tu encargo tiene dos entregables, para cada escenario:

a) Aplica el criterio de la lección 02 y decide cuántos tracks necesita cada petición, y con qué patrón se resuelve cada uno — antes de escribir ningún código de ejecución.

b) Corre el sistema completo sobre los tres escenarios, en el mismo proceso (uno después del otro), y confirma el estado final del Blackboard para cada uno que involucre una reserva real.


La solución completa (el entregable)

Ver la solución 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)


@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


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


# ======================================================================
# Escenario 1: Sofía -- pipeline + fan-out + handoff (los tres patrones)
# ======================================================================
print("========== ESCENARIO 1 ==========")
REQUEST_1 = (
    "Sofía: cotiza y reserva Boardroom pro 4h para la reunión trimestral, "
    "validando la política de cancelación antes de confirmar. Aparte, compara "
    "Focus y Studio pro 4h por si el equipo crece, y cotiza el Focus pro 2h "
    "para la sesión de feedback -- avísame qué pasa si alguien no llega a esa sesión."
)
print(REQUEST_1)

bb1 = Blackboard()
bb1.write("supervisor", member="Sofía")

PIPELINE_STAGES_BR = [
    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_BR = {"room": "Boardroom", "tier": "pro", "hours": 4, "member": "Sofía"}
script_quote_br = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Boardroom", "tier": "pro", "hours": 4}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Boardroom pro 4h cuesta 25600 centavos."}]},
]
script_policy_br = [
    {"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."
        )}]},
]
script_confirm_br = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "book_room",
         "input": {"room": "Boardroom", "tier": "pro", "hours": 4, "member": "Sofía"}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Reservé Boardroom pro 4h para Sofía (confirmación #1)."}]},
]


def job1_book_boardroom():
    return run_pipeline(PIPELINE_STAGES_BR, [script_quote_br, script_policy_br, script_confirm_br], INITIAL_PAYLOAD_BR)


script_pricing_1 = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Focus", "tier": "pro", "hours": 4}},
        {"type": "tool_use", "id": "toolu_02", "name": "get_quote",
         "input": {"room": "Studio", "tier": "pro", "hours": 4}},
    ]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": (
            "Focus pro 4h: 8000 centavos. Studio pro 4h: 12800 centavos. "
            "Focus es la opción más barata de las dos."
        )}]},
]


def job1_compare_rooms():
    return run_specialist("pricing_agent", "Compara Focus y Studio pro 4h.", script_pricing_1)


script_feedback_booking = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Focus", "tier": "pro", "hours": 2}}]},
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_02", "name": HANDOFF_TOOL_NAME,
         "input": {
             "receiver": "policy_agent",
             "reason": "pregunta 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": 2, "price_cents": 4000},
         }}]},
]
script_feedback_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_feedback = {"booking_agent": script_feedback_booking, "policy_agent": script_feedback_policy}


def job1_feedback_no_show():
    return run_with_handoff("booking_agent", "Cotiza Focus pro 2h. ¿Qué pasa si no llego?", model_scripts_feedback)


jobs1 = {
    "book_boardroom": job1_book_boardroom,
    "compare_rooms": job1_compare_rooms,
    "feedback_no_show": job1_feedback_no_show,
}
results1 = run_tracks_parallel(jobs1)

for key in sorted(results1):
    print(f"--- {key} ---")
    if key == "book_boardroom":
        payload, _ = results1[key]
        print("  payload final:", payload)
    elif key == "compare_rooms":
        final, _ = results1[key]
        print("  respuesta:", final["content"][0]["text"])
    elif key == "feedback_no_show":
        final, trace = results1[key]
        print(f"  {trace[0]['agent']} cedió el turno a {trace[0]['package'].receiver}")
        print("  respuesta final:", final["content"][0]["text"])

payload_br, _ = results1["book_boardroom"]
bb1.write("booking_agent", room=payload_br["room"], tier=payload_br["tier"],
          hours=payload_br["hours"], price_cents=payload_br["price_cents"])
bb1.write("booking_agent", booking_id=payload_br["booking_id"])
print()
print("Blackboard final escenario 1:", bb1)
no_show_ctx_1 = results1["feedback_no_show"][1][0]["package"].context
print("cargo no-presentación sesión de feedback:", no_show_ctx_1["price_cents"] // 2)

# ======================================================================
# Escenario 2: Diego -- solo pipeline + fan-out (SIN handoff: la pregunta
# de política ya nace en policy_agent, ningún agente necesita ceder el
# turno a mitad de camino)
# ======================================================================
print()
print("========== ESCENARIO 2 ==========")
REQUEST_2 = (
    "Diego: reserva el Focus pro 2h para la entrevista de mañana, validando la "
    "política de cancelación antes de confirmar. Aparte, ¿qué pasa si un "
    "candidato no llega a una entrevista reservada?"
)
print(REQUEST_2)

bb2 = Blackboard()
bb2.write("supervisor", member="Diego")

INITIAL_PAYLOAD_D = {"room": "Focus", "tier": "pro", "hours": 2, "member": "Diego"}
script_quote_d = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Focus", "tier": "pro", "hours": 2}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Focus pro 2h cuesta 4000 centavos."}]},
]
script_policy_d = [
    {"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."
        )}]},
]
script_confirm_d = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "book_room",
         "input": {"room": "Focus", "tier": "pro", "hours": 2, "member": "Diego"}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Reservé Focus pro 2h para Diego (confirmación #2)."}]},
]


def job2_book_focus():
    return run_pipeline(PIPELINE_STAGES_BR, [script_quote_d, script_policy_d, script_confirm_d], INITIAL_PAYLOAD_D)


script_no_show_general = [
    {"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."
        )}]},
]


def job2_no_show_policy():
    return run_specialist("policy_agent", "¿Qué pasa si un candidato no llega a una entrevista reservada?", script_no_show_general)


jobs2 = {"book_focus": job2_book_focus, "no_show_policy": job2_no_show_policy}
results2 = run_tracks_parallel(jobs2)

for key in sorted(results2):
    print(f"--- {key} ---")
    if key == "book_focus":
        payload, _ = results2[key]
        print("  payload final:", payload)
    else:
        final, _ = results2[key]
        print("  respuesta:", final["content"][0]["text"])

payload_d, _ = results2["book_focus"]
bb2.write("booking_agent", room=payload_d["room"], tier=payload_d["tier"],
          hours=payload_d["hours"], price_cents=payload_d["price_cents"])
bb2.write("booking_agent", booking_id=payload_d["booking_id"])
print()
print("Blackboard final escenario 2:", bb2)

# ======================================================================
# Escenario 3: Camila -- SOLO handoff (ni pipeline ni un segundo track de
# fan-out: la petición completa vive en una sola cadena emisor->receptor)
# ======================================================================
print()
print("========== ESCENARIO 3 ==========")
REQUEST_3 = "Camila: cotiza el Studio pro 3h para el webinar. Y aparte, ¿qué pasa si no llego a esa reserva?"
print(REQUEST_3)

script_camila_booking = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Studio", "tier": "pro", "hours": 3}}]},
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_02", "name": HANDOFF_TOOL_NAME,
         "input": {
             "receiver": "policy_agent",
             "reason": "pregunta de no-presentación, fuera de mi expertise",
             "task": "¿qué pasa si un miembro no se presenta a una reserva confirmada?",
             "context": {"room": "Studio", "tier": "pro", "hours": 3, "price_cents": 9600},
         }}]},
]
script_camila_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."
        )}]},
]
final_c, trace_c = run_with_handoff(
    "booking_agent", "Cotiza Studio pro 3h. ¿Qué pasa si no llego?",
    {"booking_agent": script_camila_booking, "policy_agent": script_camila_policy},
)
print(f"{trace_c[0]['agent']} cedió el turno a {trace_c[0]['package'].receiver}")
print("respuesta final:", final_c["content"][0]["text"])
ctx_c = trace_c[0]["package"].context
print("cargo no-presentación:", ctx_c["price_cents"] // 2)
print("reservas totales en BOOKINGS tras el Escenario 3 (sigue en 2 -- Camila solo cotizó,")
print("  el guion nunca llamó a book_room):", len(rt.BOOKINGS))

Qué esperar:

========== ESCENARIO 1 ==========
Sofía: cotiza y reserva Boardroom pro 4h para la reunión trimestral, validando la política de cancelación antes de confirmar. Aparte, compara Focus y Studio pro 4h por si el equipo crece, y cotiza el Focus pro 2h para la sesión de feedback -- avísame qué pasa si alguien no llega a esa sesión.
--- book_boardroom ---
  payload final: {'room': 'Boardroom', 'tier': 'pro', 'hours': 4, 'member': 'Sofía', 'price_cents': 25600, '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: Focus pro 4h: 8000 centavos. Studio pro 4h: 12800 centavos. Focus es la opción más barata de las dos.
--- feedback_no_show ---
  booking_agent cedió el turno a policy_agent
  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.

Blackboard final escenario 1: Blackboard(member='Sofía', room='Boardroom', tier='pro', hours=4, price_cents=25600, booking_id=1, log=[WriteLogEntry(seq=1, writer='supervisor', field='member', value='Sofía'), WriteLogEntry(seq=2, writer='booking_agent', field='room', value='Boardroom'), WriteLogEntry(seq=3, writer='booking_agent', field='tier', value='pro'), WriteLogEntry(seq=4, writer='booking_agent', field='hours', value=4), WriteLogEntry(seq=5, writer='booking_agent', field='price_cents', value=25600), WriteLogEntry(seq=6, writer='booking_agent', field='booking_id', value=1)])
cargo no-presentación sesión de feedback: 2000

========== ESCENARIO 2 ==========
Diego: reserva el Focus pro 2h para la entrevista de mañana, validando la política de cancelación antes de confirmar. Aparte, ¿qué pasa si un candidato no llega a una entrevista reservada?
--- book_focus ---
  payload final: {'room': 'Focus', 'tier': 'pro', 'hours': 2, 'member': 'Diego', 'price_cents': 4000, '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': 2, 'confirmed': True}
--- no_show_policy ---
  respuesta: 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.

Blackboard final escenario 2: Blackboard(member='Diego', room='Focus', tier='pro', hours=2, price_cents=4000, booking_id=2, log=[WriteLogEntry(seq=7, writer='supervisor', field='member', value='Diego'), WriteLogEntry(seq=8, writer='booking_agent', field='room', value='Focus'), WriteLogEntry(seq=9, writer='booking_agent', field='tier', value='pro'), WriteLogEntry(seq=10, writer='booking_agent', field='hours', value=2), WriteLogEntry(seq=11, writer='booking_agent', field='price_cents', value=4000), WriteLogEntry(seq=12, writer='booking_agent', field='booking_id', value=2)])

========== ESCENARIO 3 ==========
Camila: cotiza el Studio pro 3h para el webinar. Y aparte, ¿qué pasa si no llego a esa reserva?
booking_agent cedió el turno a policy_agent
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.
cargo no-presentación: 4800
reservas totales en BOOKINGS tras el Escenario 3 (sigue en 2 -- Camila solo cotizó,
  el guion nunca llamó a book_room): 2

El razonamiento por escenario:

Escenario 1 (Sofía) — los tres patrones, igual que la petición de Ana. book_boardroom necesita orden fijo (cotizar → validar → confirmar) → pipeline. compare_rooms no depende de nada más → fan-out plano. feedback_no_show empieza como una cotización de booking_agent y se topa, a mitad de camino, con una pregunta de política → handoff, dentro de la tercera rama del fan-out. booking_id: 1 porque es la primera reserva real del proceso (Sofía corre primero en este script); el cargo de no-presentación de la sesión de feedback (4000 // 2 = 2000) sale del context del handoff, no de ningún cálculo dentro de policy_agent.

Escenario 2 (Diego) — solo dos patrones: pipeline + fan-out, SIN handoff. book_focus sigue necesitando el mismo pipeline de tres etapas. Pero la pregunta de no-presentación de Diego —"¿qué pasa si un candidato no llega a una entrevista reservada?"— es una pregunta de política general, sin ningún dato de una reserva puntual: el supervisor la rutea directamente a policy_agent desde el principio, como un segundo track de fan-out plano. Ningún agente necesita ceder el turno a mitad de camino, porque nadie empezó a trabajar esta pregunta pensando que era su propio dominio. booking_id: 2 porque este script ya había reservado el Boardroom de Sofía antes.

Escenario 3 (Camila) — solo handoff, ni pipeline ni un segundo track. A diferencia de los otros dos, esta petición no tiene ninguna palabra sobre "reservar" ni "validar antes de confirmar" — solo pide cotizar, y la segunda mitad de la frase depende directamente de esa cotización (el cargo de no-presentación necesita el price_cents que booking_agent ya calculó). No hace falta un pipeline (no hay ninguna etapa de confirmación) ni un segundo track de fan-out (no hay ninguna sub-pregunta independiente) — la petición completa vive en una sola cadena booking_agentpolicy_agent, resuelta con run_with_handoff solo. reservo_tools.BOOKINGS sigue teniendo exactamente 2 reservas (Boardroom y Focus) después de este escenario, porque Camila nunca pidió reservar — solo cotizar.


Errores comunes

  1. Forzar un pipeline o un fan-out adicional en el Escenario 3, "para que se parezca más" a los otros dos. El punto de este escenario es justamente reconocer que una petición compuesta puede necesitar solo un patrón — agregar estructura que la petición no pide es exactamente el tipo de sobre-ingeniería que el Módulo 1 de esta guía ya advirtió contra, desde el principio del ecosistema.

  2. Buscar un handoff en el Escenario 2 porque "también hay una pregunta de no-presentación", como en el Escenario 1. Son dos preguntas con la misma superficie de vocabulario pero de naturaleza distinta: la de Sofía depende de una reserva puntual que booking_agent ya estaba cotizando (por eso hace falta ceder el turno); la de Diego es una pregunta general que nace, desde el principio, en el dominio de policy_agent (por eso no hace falta ningún handoff).

  3. Confundir booking_id: 1 del Escenario 1 con booking_id: 1 de otras lecciones del módulo. Este mini-proyecto corre los tres escenarios en el mismo proceso, uno después del otro — el booking_id de Sofía es 1 porque es la primera reserva de ESTE script, no porque coincida por casualidad con el de la petición de Ana en la lección 07 (un proceso distinto).

  4. Ejecutar los tres escenarios en un orden distinto y esperar los mismos booking_id. Como en todos los mini-proyectos de esta guía, el orden de ejecución determina la numeración — si corrieras el Escenario 2 antes que el 1, Diego se quedaría con booking_id: 1 y Sofía con 2.


Ejercicios

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

Ejecuta los tres escenarios del encargo tú mismo y confirma, línea por línea, que tu salida coincide con la solución completa. Presta especial atención a los booking_id de los Escenarios 1 y 2 — deben salir 1 y 2 respectivamente, en ese orden, si corriste el script completo de principio a fin.

Ver solución

No hay una única "solución de código" para esta parte — es una verificación: si tu salida coincide con la de la solución completa, tu Reservo desechable arrancó limpio y los tres escenarios corrieron en el orden esperado.

Ejercicio 2: Agrega un cuarto escenario, con los tres patrones en un orden distinto (Medio)

Diseña un cuarto escenario donde el fan-out sea la parte "principal" (dos sub-preguntas independientes) y el handoff aparezca dentro de la parte del pipeline en vez de dentro del fan-out —por ejemplo: "Marta: reserva el Studio pro 3h para el taller, validando la política antes de confirmar; si te preguntan por el cargo de no-presentación en esa etapa, resuélvelo directo con policy_agent. Aparte, compara Focus y Boardroom pro 3h, y dime cuál sala tiene más capacidad." Decide, sin ejecutar código todavía, cuántos tracks necesita y con qué patrón se resuelve cada uno.

Ver solución

Esta petición necesita, otra vez, tres tracks: un pipeline para la reserva del Studio (cotizar → validar política → confirmar), y dos tracks de fan-out plano para las dos preguntas de comparación ("compara Focus y Boardroom" y "cuál sala tiene más capacidad" — esta última respondida con list_rooms, no con get_quote, ya que pregunta por capacidad, no por precio). A diferencia de la petición de Ana o del Escenario 1 de este mini-proyecto, no hay ningún handoff en esta versión: la frase "si te preguntan por el cargo... resuélvelo directo con policy_agent" es, en los hechos, una instrucción para que el pipeline incluya una etapa de política —exactamente la etapa validate_policy que ya construyó el Módulo 3—, no una señal de que un agente vaya a ceder el turno a mitad de camino. Esto confirma un punto importante del criterio de la lección 02: una pregunta de política DENTRO de un flujo con orden fijo se resuelve con una etapa de pipeline, no con un handoff — el handoff es específicamente para cuando la necesidad de otro especialista se descubre a mitad de una tarea que no lo anticipaba, no para cuando ya se sabía de antemano que hacía falta validar una política en un punto fijo del flujo.

Ejercicio 3: Mide cuántos tracks necesitó cada escenario del encargo (Difícil)

Sin volver a leer las peticiones, construye una tabla —a partir de los PLAN que aplicaste en la parte (a) del encargo— que cuente, por escenario, cuántos tracks de cada patrón (pipeline, fanout, handoff) usó cada uno. ¿Qué proporción de los tres escenarios necesitó los tres patrones a la vez?

Ver solución
from collections import Counter

ESCENARIOS = {
    "Sofía": ["pipeline", "fanout", "handoff"],
    "Diego": ["pipeline", "fanout"],
    "Camila": ["handoff"],
}
for nombre, patrones in ESCENARIOS.items():
    print(f"{nombre}: {len(patrones)} track(s) -- {Counter(patrones)}")

full_combo = sum(1 for patrones in ESCENARIOS.values() if set(patrones) == {"pipeline", "fanout", "handoff"})
print(f"\nescenarios con los tres patrones a la vez: {full_combo}/{len(ESCENARIOS)}")

Salida esperada:

Sofía: 3 track(s) -- Counter({'pipeline': 1, 'fanout': 1, 'handoff': 1})
Diego: 2 track(s) -- Counter({'pipeline': 1, 'fanout': 1})
Camila: 1 track(s) -- Counter({'pipeline': 0, 'fanout': 0, 'handoff': 1})

escenarios con los tres patrones a la vez: 1/3

Explicación: solo 1 de 3 escenarios de este encargo necesitó los tres patrones a la vez — la misma proporción, aproximadamente, que ya viste en otros módulos de esta guía sobre "cuántas peticiones reales necesitan el mecanismo más completo, contra cuántas se resuelven con menos". El punto central de este mini-proyecto, y de todo el Módulo 7, no es que toda petición compuesta necesite los cinco patrones — es tener el criterio para reconocer, caso por caso, exactamente cuántos hacen falta y cuáles.


Resumen y siguiente paso

  • El mini-proyecto no agregó ningún concepto nuevo: aplicó las seis lecciones anteriores —el criterio de las tres preguntas, el pipeline como pieza, el fan-out generalizado, el handoff dentro de una rama, el blackboard escrito fuera de la sección paralela, la corrida completa— sobre tres peticiones de Reservo nuevas.
  • Los tres escenarios tuvieron formas distintas: Sofía necesitó los tres patrones (como Ana, en la lección 07); Diego necesitó dos (pipeline + fan-out, sin handoff); Camila necesitó solo uno (handoff puro, sin pipeline ni un segundo track).
  • Solo 1 de los 3 escenarios usó los tres patrones a la vez — confirmando, con números, que "petición compuesta" no significa automáticamente "los cinco patrones a la vez".
  • El criterio completo de este módulo —las tres preguntas de la lección 02, aplicadas con disciplina— es lo que distingue estos tres casos entre sí, sin necesitar ningún mecanismo nuevo para ninguno.

Con esto termina el Módulo 7. Combinaste los cinco patrones de los Módulos 2 a 6 sobre peticiones reales de Reservo —una que necesita los tres a la vez, una que necesita dos, una que necesita solo uno—, con el criterio para decidir cuál le corresponde a cada parte, sin inventar ningún mecanismo de coordinación nuevo. En el Módulo 8 construimos el capstone de la guía: el sistema multi-agente completo de Reservo —supervisor + los tres especialistas, sobre un blackboard compartido— entregado como proyecto final, con una demo de al menos dos peticiones de forma distinta y el conteo de costo de coordinación de cada una.


Recursos adicionales

  1. Anthropic — Building effective agents — El principio de usar el mecanismo más simple que resuelva cada parte de la tarea, ahora aplicado tres veces sobre peticiones de forma distinta.
  2. Anthropic — Multi-agent research system — Un caso real donde reconocer cuánta estructura necesita CADA tarea —ni más ni menos— determinó el diseño final del sistema.
  3. Anthropic — Agent SDK overview — Cómo se ve, en código de producción, un sistema que aplica este mismo criterio de composición — el destino final del Módulo 8 de esta guía.
  4. Python — collections.Counter — La estructura usada en el Ejercicio 3 para contar cuántos tracks de cada patrón usó cada escenario.